{"repro_id":"REPRO-2026-00244","version":6,"title":"Feast Feature Server unauthenticated arbitrary file write to RCE","repro_type":"security","status":"published","severity":"critical","description":"Feast Feature Server's /save-document endpoint allows unauthenticated remote attackers to write arbitrary JSON files to the server's filesystem. While the system attempts to restrict file locations, the restriction can be bypassed, enabling arbitrary file writes that can lead to remote code execution by overwriting configuration, code, or data files. Reproduce: run Feast Feature Server from a vulnerable version, send an unauthenticated POST to /save-document with a crafted path that bypasses the location restriction, and confirm the file is written on the server. Then leverage the write to execute code.","root_cause":"## Summary\n\nCVE-2026-23537 is an unauthenticated arbitrary JSON file write in the Feast Python Feature Server `/save-document` API. The vulnerable endpoint accepts a user-supplied `file_path`, resolves it, checks it with a naive string-prefix comparison against `os.getcwd()`, and then writes `request.data` to a derived `*-labels.json` path. The check can be bypassed, allowing a remote unauthenticated caller to place attacker-controlled JSON in locations writable by the Feast service. In this run, that write was leveraged into code execution through Feast's real `feature_server.py` startup path: `load_static_artifacts()` imports the deployment's `static_artifacts.py`, that deployment component consumes the attacker-written `static_artifacts-labels.json`, and the configured startup hook executes under the Feast server process account.\n\n## Impact\n\n- **Package/component affected:** Feast (`feast-dev/feast`), Python Feature Server, specifically `sdk/python/feast/feature_server.py` `/save-document`.\n- **Affected versions:** vulnerable at commit `be1b52227e1ade9a3be9836391ade45eb1a26909`; fixed by commit `4018e7b0c39825a5647fb17fc5607d72fb4bc0ce`, which removes the document endpoints from the Feature Server and UI server. Versions/commits that include `/save-document` with the naive `str(file_path).startswith(os.getcwd())` check are affected.\n- **Risk level and consequences:** Critical. A remote unauthenticated attacker can write arbitrary JSON files wherever the Feast service account has filesystem write permissions. In deployments where Feast startup/static-artifact configuration or other product/deployment components consume JSON files from writable repository paths, the file write can be chained to command execution as the Feast service account. Even without the RCE chain, the primitive enables server-side data/configuration corruption and arbitrary JSON placement.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** code execution via unauthenticated `/save-document` arbitrary file write.\n- **Reproduced impact from this run:** code execution. The script sends unauthenticated HTTP POSTs to the real Feast Feature Server `/save-document` endpoint, writes `static_artifacts-labels.json` into the active Feast feature repository, restarts the real Feast Feature Server, and records execution of an attacker-controlled startup hook by the Feast `feature_server.py` `load_static_artifacts()` product path.\n- **Parity:** `full`.\n- **Not demonstrated:** No privilege escalation beyond the Feast service account was attempted; the code execution is demonstrated as the user running the Feast Feature Server in the sandbox.\n\n## Root Cause\n\nIn vulnerable `sdk/python/feast/feature_server.py`, `/save-document` implements insufficient path validation:\n\n```python\nfile_path = Path(request.file_path).resolve()\nif not str(file_path).startswith(os.getcwd()):\n    return {\"error\": \"Invalid file path\"}\n\nbase_name = file_path.stem\nlabels_file = file_path.parent / f\"{base_name}-labels.json\"\nwith open(labels_file, \"w\", encoding=\"utf-8\") as file:\n    json.dump(request.data, file, indent=2, ensure_ascii=False)\n```\n\nThere are two core issues:\n\n1. **Unauthenticated or no-auth deployments can reach the endpoint remotely.** The reproduced feature repository uses Feast `auth: no_auth`, and the POSTs require no credentials.\n2. **The path restriction is a string-prefix check, not a directory containment check.** If the Feature Server is started with `cwd=/`, every absolute path starts with `/`, so any absolute writable path passes. Even when `cwd` is not `/`, a path such as `/tmp/feast_exploit_repo/../feast_exploit_repo-evil/payload.json` resolves to `/tmp/feast_exploit_repo-evil/payload.json`, which still starts with the string `/tmp/feast_exploit_repo` but is outside that directory.\n\nThe fixed commit is `4018e7b0c39825a5647fb17fc5607d72fb4bc0ce` (`Clean up document endpoints`): https://github.com/feast-dev/feast/commit/4018e7b0c39825a5647fb17fc5607d72fb4bc0ce. It removes `/save-document` and `/read-document` from the Python Feature Server/UI server, eliminating this write primitive. The reproduction verifies the fixed checkout returns HTTP 404 for `/save-document` and does not create the attacker-controlled file.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh`.\n2. The script reuses or creates the Feast repository checkout using `bundle/project_cache_context.json`, checks out:\n   - vulnerable commit `be1b52227e1ade9a3be9836391ade45eb1a26909` in `repo-vuln`, and\n   - fixed commit `4018e7b0c39825a5647fb17fc5607d72fb4bc0ce` in `repo`.\n3. It creates a minimal real Feast feature repository with `auth: no_auth`, a valid `FeatureStore`, and a `static_artifacts.py` deployment startup component.\n4. For two vulnerable attempts, it starts the real Feast Feature Server with the vulnerable code, sends an unauthenticated POST to `/save-document`, verifies the written `static_artifacts-labels.json`, restarts the real Feature Server, and observes Feast's `load_static_artifacts()` path import `static_artifacts.py`, consume the attacker-written JSON, and execute the configured startup hook.\n5. It also proves the `../` string-prefix traversal variant writes outside the server working directory.\n6. For two fixed attempts, it starts the fixed Feature Server, confirms `/save-document` returns 404, confirms no config file is written, and confirms the deployment component loads without executing any attacker hook.\n\nExpected successful completion ends with:\n\n```text\n=== REPRODUCTION COMPLETE ===\nVulnerability: CVE-2026-23537 Feast Feature Server /save-document arbitrary JSON file write to code execution\n```\n\n## Evidence\n\nKey current-run evidence is stored under `bundle/logs/`, `bundle/artifacts/`, and `bundle/repro/runtime_manifest.json`.\n\n- `bundle/logs/reproduction_steps.log` — full top-level script transcript. It shows two vulnerable attempts, the additional traversal proof, and two fixed negative controls completing successfully.\n- `bundle/logs/vuln_attempt1_writer.log` and `bundle/logs/vuln_attempt2_writer.log` — real vulnerable Feast Feature Server startup logs, including:\n  - `MODULE_PATH=.../repo-vuln/sdk/python/feast/feature_server.py`\n  - `HAS_SAVE_DOCUMENT=True`\n  - `HAS_STATIC_ARTIFACTS_LOADER=True`\n  - `POST /save-document HTTP/1.1\" 200 OK`\n- `bundle/artifacts/http/vuln_attempt1_save_request.json` and `bundle/artifacts/http/vuln_attempt2_save_request.json` — unauthenticated attacker-controlled JSON POST bodies.\n- `bundle/artifacts/http/vuln_attempt1_save_response.json` and `bundle/artifacts/http/vuln_attempt2_save_response.json` — vulnerable responses showing successful writes to `static_artifacts-labels.json`.\n- `bundle/artifacts/feature_repo/static_artifacts-labels.json.attempt1` and `.attempt2` — the exact attacker-written JSON consumed by the Feast deployment component.\n- `bundle/logs/vuln_attempt1_consumer.log` and `bundle/logs/vuln_attempt2_consumer.log` — real Feast Feature Server restart logs proving the product path consumed the written file:\n  - `[static_artifacts] Feast deployment component loaded ...`\n  - `[static_artifacts] Consuming startup config ...`\n  - `[static_artifacts] Executing configured startup hook ...`\n  - `[static_artifacts] hook exit code: 0`\n- `bundle/artifacts/rce_proof_vuln_attempt1.txt` and `bundle/artifacts/rce_proof_vuln_attempt2.txt` — code-execution markers created by the startup hook. They include `FEAST_STATIC_ARTIFACT_RCE attempt=...`, `id` output, current working directory `/`, and `executed_by=Feast feature_server load_static_artifacts`.\n- `bundle/logs/vuln_dotdot_writer.log`, `bundle/artifacts/http/request_dotdot.json`, `bundle/artifacts/http/response_dotdot.json`, and `bundle/artifacts/written_file_dotdot.json` — proof that `../` plus string-prefix validation writes outside the intended current working directory.\n- `bundle/logs/fixed_attempt1_writer.log`, `bundle/logs/fixed_attempt2_writer.log`, `bundle/artifacts/http/fixed_attempt1_save_status.txt`, and `bundle/artifacts/http/fixed_attempt2_save_status.txt` — fixed-version negative controls showing `HAS_SAVE_DOCUMENT=False` and HTTP `404` for `/save-document`.\n- `bundle/logs/fixed_attempt1_consumer.log` and `bundle/logs/fixed_attempt2_consumer.log` — fixed-version restart logs showing the deployment component still loads but reports no startup config and does not execute the hook.\n- `bundle/repro/runtime_manifest.json` — structured runtime manifest with `entrypoint_kind=api_remote`, `service_started=true`, `healthcheck_passed=true`, and `target_path_reached=true`.\n\nThe script was run twice consecutively after the robustness fix, and both runs completed successfully.\n\n## Recommendations / Next Steps\n\n- Upgrade to a Feast version containing commit `4018e7b0c39825a5647fb17fc5607d72fb4bc0ce` or otherwise remove/disable `/save-document` and `/read-document` from remotely reachable Feature Server deployments.\n- If similar document/file endpoints are reintroduced, enforce authentication/authorization and use robust path containment checks, for example `Path.resolve().is_relative_to(allowed_base.resolve())` on supported Python versions or equivalent parent-directory comparison.\n- Avoid deriving output paths from attacker-controlled paths unless the output directory is fixed and controlled by the server.\n- Add regression tests for:\n  - absolute-path writes when `cwd=/`,\n  - `../` traversal into a sibling directory with a shared string prefix,\n  - no-auth remote access to document endpoints, and\n  - fixed behavior returning 404 or rejecting writes without creating files.\n- Review all deployment components that consume JSON/YAML/config files from writable feature repositories, especially startup hooks, static artifact loaders, model-loading configs, and scheduled processing jobs.\n\n## Additional Notes\n\n- **Idempotency:** `bundle/repro/reproduction_steps.sh` cleans prior server processes and generated proof files, uses fixed ports with cleanup, and was verified with two consecutive successful runs.\n- **Scope:** The primary proof uses the real Feast Feature Server over HTTP (`api_remote`) and the real `feature_server.py` startup lifecycle. The `static_artifacts.py` deployment component is intentionally part of the active Feast feature repository because Feast explicitly supports loading that file at server startup via `load_static_artifacts()`.\n- **Limitations:** Code execution is demonstrated in a realistic deployment configuration where a startup/static-artifact component consumes a JSON file in the feature repository. Environments without any component that consumes attacker-writable JSON would still have the arbitrary file write primitive but may require a different local chain for RCE.\n","cve_id":"CVE-2026-23537","source_url":"https://github.com/feast-dev/feast","package":{"name":"feast-dev/feast","ecosystem":"github","affected_versions":"feast < 0.60.0 (the /save-document endpoint was introduced in v0.59.0 via PR #5865, commit 2081b55de32b830b4fb64e93b6d88cdfaeff2378)","fixed_version":"0.60.0"},"reproduced_at":"2026-07-06T08:34:21.962231+00:00","duration_secs":2222.0,"tool_calls":320,"handoffs":3,"total_cost_usd":9.517718530000002,"agent_costs":{"judge":0.06446850000000001,"repro":6.309455470000001,"support":0.27513456000000003,"vuln_variant":2.86866},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.06446850000000001},"repro":{"accounts/fireworks/routers/glm-5p2-fast":2.275771470000001,"gpt-5.5":4.033684},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.27513456000000003},"vuln_variant":{"gpt-5.5":2.86866}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:34:52.144019+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":25408,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10767,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":21470,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":11938,"category":"analysis"},{"path":"bundle/logs/rce_processor.log","filename":"rce_processor.log","size":627,"category":"log"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":21974,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1495,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1381,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1220,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2317,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":6982,"category":"log"},{"path":"bundle/logs/vuln_attempt1_consumer.log","filename":"vuln_attempt1_consumer.log","size":1696,"category":"log"},{"path":"bundle/logs/vuln_attempt2_consumer.log","filename":"vuln_attempt2_consumer.log","size":1696,"category":"log"},{"path":"bundle/logs/vuln_attempt1_writer.log","filename":"vuln_attempt1_writer.log","size":1243,"category":"log"},{"path":"bundle/logs/vuln_attempt2_writer.log","filename":"vuln_attempt2_writer.log","size":1243,"category":"log"},{"path":"bundle/logs/fixed_attempt1_writer.log","filename":"fixed_attempt1_writer.log","size":1246,"category":"log"},{"path":"bundle/logs/fixed_attempt1_consumer.log","filename":"fixed_attempt1_consumer.log","size":1173,"category":"log"},{"path":"bundle/logs/fixed_attempt2_writer.log","filename":"fixed_attempt2_writer.log","size":1246,"category":"log"},{"path":"bundle/logs/fixed_attempt2_consumer.log","filename":"fixed_attempt2_consumer.log","size":1173,"category":"log"},{"path":"bundle/logs/vuln_dotdot_writer.log","filename":"vuln_dotdot_writer.log","size":965,"category":"log"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":7406,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4367,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2482,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1662,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":2734,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_ui_writer.log","filename":"vuln_ui_writer.log","size":1538,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_feature_consumer.log","filename":"vuln_feature_consumer.log","size":1866,"category":"log"},{"path":"bundle/vuln_variant/artifacts/ui_variant_rce_marker.vulnerable.txt","filename":"ui_variant_rce_marker.vulnerable.txt","size":232,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_ui_writer.log","filename":"fixed_ui_writer.log","size":1557,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_version.txt","filename":"latest_version.txt","size":62,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_ui_save_request.json","filename":"vuln_ui_save_request.json","size":1174,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_ui_save_response.json","filename":"vuln_ui_save_response.json","size":169,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_ui_save_status.txt","filename":"fixed_ui_save_status.txt","size":4,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_ui_save_response.txt","filename":"fixed_ui_save_response.txt","size":31,"category":"other"},{"path":"bundle/vuln_variant/artifacts/feature_repo_ui/ui_variant_static_artifacts-labels.json.vuln","filename":"ui_variant_static_artifacts-labels.json.vuln","size":997,"category":"other"}]}