{"repro_id":"REPRO-2026-00201","version":9,"title":"Unauthenticated RCE in Langflow via public flow build endpoint","repro_type":"security","status":"published","severity":"critical","description":"The POST /api/v1/build_public_tmp/{flow_id}/flow endpoint in Langflow (<1.9.0) accepts attacker-controlled data containing arbitrary Python code in custom component nodes. The unauthenticated endpoint passes the provided data to start_flow_build(), which builds a graph and executes the custom component code via exec() without sandboxing. This allows an unauthenticated, remote attacker to obtain RCE with a single HTTP request. Affected: langflow pypi <1.9.0. Fixed: 1.9.0. Setup: run a vulnerable Langflow instance (e.g., langflowai/langflow:1.8.1 or pip install langflow==1.8.1), create a public flow, then POST to the public build endpoint with a custom component whose top-level code contains an os.system() payload. CISA KEV added 2026-03-25.","root_cause":"# Root Cause Analysis: CVE-2026-33017\n\n## Summary\n\nCVE-2026-33017 is an unauthenticated remote code execution (RCE) vulnerability in\nLangflow prior to version 1.9.0. The public flow-build endpoint\n`POST /api/v1/build_public_tmp/{flow_id}/flow` accepts an attacker-controlled\n`data` parameter (`FlowDataRequest`) containing arbitrary Python code inside a\ncustom component node. Because the endpoint is intentionally unauthenticated for\npublic flows, any remote attacker can reach it. The supplied flow definition is\npassed through `start_flow_build()` → `build_graph_from_data()` →\n`Graph.from_payload()` and ultimately to the custom-component loader, which\nextracts the `code` field and executes it with `exec()` inside\n`prepare_global_scope()` (in `lfx/custom/validate.py`) without any sandboxing.\nA module-level assignment such as `_rce = os.system(...)` is an `ast.Assign`\nnode that `prepare_global_scope()` collects and `exec()`s at graph-build time,\nyielding arbitrary command execution with the privileges of the Langflow server\nprocess. A single HTTP request is sufficient.\n\n## Impact\n\n- **Product:** Langflow (PyPI package `langflow`; Docker image `langflowai/langflow`)\n- **Affected versions:** `langflow < 1.9.0` (reproduction uses `1.8.1` as the\n  vulnerable image).\n- **Patched versions:** `>= 1.9.0` (the public build endpoint hardcodes\n  `data=None` and loads the stored flow from the database only).\n- **Risk level:** Critical (CISA KEV added 2026-03-25).\n- **Consequences:** An unauthenticated, remote attacker can run arbitrary system\n  commands, read environment variables (including LLM API keys / cloud\n  credentials), access/modify the database and flow data, and establish\n  persistence. The server process ran as `uid=1000(user) gid=0(root)` in the\n  container image.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unauthenticated remote code execution\n  (code execution) via a single HTTP request to the public build endpoint.\n- **Reproduced impact from this run:** Confirmed code execution. The vulnerable\n  `langflowai/langflow:1.8.1` container wrote `/tmp/rce-proof` containing the\n  output of the `id` command (`uid=1000(user) gid=0(root) groups=0(root)`) plus a\n  unique per-attempt token, after receiving an **unauthenticated** HTTP POST to\n  `/api/v1/build_public_tmp/{flow_id}/flow`. The fixed\n  `langflowai/langflow:1.9.0` container did **not** write the proof file under\n  the identical request (negative control).\n- **Parity:** `full`.\n- **Not demonstrated:** None relevant; the claimed unauthenticated-RCE impact\n  was directly demonstrated end-to-end against the real product.\n\n## Root Cause\n\nThe vulnerable endpoint `build_public_tmp` in\n`src/backend/base/langflow/api/v1/chat.py` (v1.8.1) declares an inbound\n`data: FlowDataRequest` parameter and forwards it directly to\n`start_flow_build()`:\n\n```python\n@router.post(\"/build_public_tmp/{flow_id}/flow\")\nasync def build_public_tmp(..., data: Annotated[FlowDataRequest | None, Body(embed=True)] = None, ...):\n    owner_user, new_flow_id = await verify_public_flow_and_get_user(flow_id=flow_id, client_id=client_id)\n    job_id = await start_flow_build(flow_id=new_flow_id, ..., data=data, ...)\n```\n\n`start_flow_build()` (`src/backend/base/langflow/api/build.py`) builds the graph\nfrom the attacker-supplied data when it is present:\n\n```python\nasync def create_graph(...):\n    if not data:\n        return await build_graph_from_db(...)\n    return await build_graph_from_data(flow_id=..., payload=data.model_dump(), ...)\n```\n\n`build_graph_from_data()` → `Graph.from_payload()` constructs vertices from the\nattacker nodes. For a custom component (`template._type == \"Component\"`), the\nloader calls `create_class(code, class_name)` in `src/lfx/src/lfx/custom/validate.py`,\nwhich calls `prepare_global_scope(module)`. That function iterates the module\nbody, collects top-level `ast.Assign` / `ast.AnnAssign` / `ast.ClassDef` /\n`ast.FunctionDef` nodes into `definitions`, compiles them, and runs:\n\n```python\nif definitions:\n    combined_module = ast.Module(body=definitions, type_ignores=[])\n    compiled_code = compile(combined_module, \"<string>\", \"exec\")\n    exec(compiled_code, exec_globals)   # <-- attacker module-level code runs here\n```\n\nTherefore a top-level `_rce = os.system(\"id > /tmp/rce-proof ...\")` executes\nduring graph construction, before any output is produced.\n\nThe only access control on the endpoint is `verify_public_flow_and_get_user()`,\nwhich merely checks that the targeted `flow_id` is marked `PUBLIC` in the\ndatabase and that a `client_id` cookie is present (any value). The attacker\ncreates the public flow themselves (using the AUTO_LOGIN superuser session), so\nthis check is satisfied trivially.\n\n**Fix (v1.9.0):** the endpoint no longer accepts a `data` parameter and hardcodes\n`data=None`, so the build always loads the stored flow definition from the\ndatabase. It also validates the stored flow with\n`validate_flow_for_current_settings()` and rejects custom components on the\npublic path (`CustomComponentValidationError` → HTTP 400). The diff is the\nremoval of `data: ... = None` from the signature and `data=data` → `data=None`\nin the `start_flow_build(...)` call.\n\n```python\n# v1.9.0\njob_id = await start_flow_build(flow_id=new_flow_id, source_flow_id=flow_id,\n    ..., data=None,  # Always None - public flows load from database only\n    ...)\n```\n\n## Reproduction Steps\n\n1. The reproduction is fully automated by\n   `bundle/repro/reproduction_steps.sh` (with helper\n   `bundle/repro/repro_attempt.py`).\n2. The script pulls `langflowai/langflow:1.8.1` (vulnerable) and\n   `langflowai/langflow:1.9.0` (fixed), then runs **2 vulnerable** and **2 fixed**\n   isolated attempts. Each attempt:\n   - starts a fresh Langflow container with `LANGFLOW_AUTO_LOGIN=true` and\n     `--backend-only`,\n   - waits for the `/health` endpoint,\n   - performs `GET /api/v1/auto_login` to obtain a superuser access token,\n   - creates a PUBLIC flow via `POST /api/v1/flows/`,\n   - sends the unauthenticated exploit `POST\n     /api/v1/build_public_tmp/{flow_id}/flow` with a `client_id` cookie and a\n     body whose `data` contains one `CustomComponent` node whose `code` holds a\n     top-level `_rce = os.system(\"id > /tmp/rce-proof && echo RCE_CONFIRMED\n     <token> >> /tmp/rce-proof\")`,\n   - polls for `/tmp/rce-proof` inside the container and copies it out as\n     evidence, then tears the container down.\n3. Expected evidence: on the vulnerable image each attempt produces\n   `logs/proof_vuln_N.txt` containing the `id` output and the unique token, with\n   `exploit_status: 200` and `proof_exists: true` in `logs/result_vuln_N.json`.\n   On the fixed image `proof_exists: false` for every attempt\n   (`logs/result_fixed_N.json`).\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full orchestrator log.\n- `bundle/logs/result_vuln_{1,2}.json` — per-attempt JSON results for the\n  vulnerable image (auto_login=200, create_flow=201, exploit=200,\n  proof_exists=true, proof_content with `uid=1000(user)...` + token).\n- `bundle/logs/proof_vuln_{1,2}.txt` — the proof file exfiltrated from the\n  vulnerable container (`id` output + `RCE_CONFIRMED <token>`).\n- `bundle/logs/result_fixed_{1,2}.json` — per-attempt JSON results for the fixed\n  image (exploit=200 but proof_exists=false).\n- `bundle/logs/container_{vuln,fixed}_{1,2}.log` — container startup/runtime\n  logs.\n- `bundle/repro/runtime_manifest.json` — structured runtime evidence\n  (`entrypoint_kind=api_remote`, `service_started=true`,\n  `healthcheck_passed=true`, `target_path_reached=true`).\n- `bundle/repro/validation_verdict.json` — structured verdict.\n\nKey excerpt from a manual run against `langflowai/langflow:1.8.1`:\n\n```json\n{\"role\":\"vuln\",\"token\":\"manualtest1\",\"auto_login_status\":200,\"create_flow_status\":201,\n \"flow_id\":\"b43e6614-...\",\"exploit_status\":200,\n \"exploit_body\":\"{\\\"job_id\\\":\\\"8449e0de-...\\\"}\",\"proof_exists\":true,\n \"proof_content\":\"uid=1000(user) gid=0(root) groups=0(root)\\nRCE_CONFIRMED manualtest1\",\n \"success\":true}\n```\n\nNegative control against `langflowai/langflow:1.9.0` (identical request):\n\n```json\n{\"role\":\"fixed\",\"token\":\"fixedtest1\",\"auto_login_status\":200,\"create_flow_status\":201,\n \"exploit_status\":200,\"proof_exists\":false,\"proof_content\":null,\"success\":false}\n```\n\nEnvironment: Docker 29.6.1; official images `langflowai/langflow:1.8.1` and\n`:1.9.0`; exploit executed via `docker exec` inside each container (the sandbox\ncannot reach published host ports, so all HTTP traffic is generated inside the\ncontainer against `http://127.0.0.1:7860`). No sanitizers were used; this is a\nnon-sanitized production-path proof.\n\n## Recommendations / Next Steps\n\n- **Upgrade** to Langflow `>= 1.9.0` immediately. The public build endpoint no\n  longer accepts client-supplied flow definitions and validates stored flows.\n- If AUTO_LOGIN must stay enabled in production, restrict network exposure of\n  the Langflow HTTP port and place it behind an authenticated reverse proxy;\n  AUTO_LOGIN issues a superuser session without credentials.\n- Consider disabling custom components entirely on public flows\n  (`allow_custom_components=false`) and enforce `access_type=PRIVATE` by default.\n- Add an integration test that posts a custom-component payload with a\n  module-level side-effect sentinel to `build_public_tmp` and asserts it never\n  fires, to prevent regressions of this fix.\n\n## Additional Notes\n\n- **Idempotency:** The script removes any prior `/tmp/rce-proof` at the start of\n  each attempt and tears down the container afterwards, so consecutive runs are\n  clean and reproducible. Verified by running two vulnerable and two fixed\n  attempts back-to-back.\n- The malicious payload is delivered as a top-level **assignment**\n  (`_rce = os.system(...)`) rather than a bare expression, because\n  `prepare_global_scope()` only `exec()`s nodes it classifies as `ast.Assign` /\n  `ast.AnnAssign` / `ast.ClassDef` / `ast.FunctionDef`; an assignment is\n  guaranteed to execute at graph-build time.\n- The flow created for the exploit uses a benign empty `data`\n  (`{\"nodes\":[],\"edges\":[]}`) simply to satisfy the `access_type=PUBLIC`\n  requirement; on the vulnerable path the attacker-supplied `data` overrides the\n  stored definition, so the stored content is irrelevant.\n- The proof file is written inside the container filesystem and copied out via\n  `docker cp` for durable evidence.\n","cve_id":"CVE-2026-33017","cwe_id":"CWE-94, CWE-95, CWE-306","package":{"name":"langflow","ecosystem":"pip","affected_versions":"<= 1.8.2 (all versions < 1.9.0)","fixed_version":"1.9.0"},"reproduced_at":"2026-07-02T16:46:15.644829+00:00","duration_secs":1508.0,"tool_calls":259,"handoffs":2,"total_cost_usd":5.454067369999998,"agent_costs":{"hypothesis_generator":0.012899,"judge":0.01336995,"repro":4.167550919999997,"support":0.08235912,"vuln_variant":1.17788838},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.012899},"judge":{"gpt-5.4-mini":0.01336995},"repro":{"accounts/fireworks/routers/glm-5p2-fast":4.167550919999997},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.08235912},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.17788838}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T16:46:16.617694+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10347,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10401,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11967,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":15556,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":930,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1419,"category":"other"},{"path":"bundle/repro/repro_attempt.py","filename":"repro_attempt.py","size":8045,"category":"script"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":828,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":923,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":6676,"category":"log"},{"path":"bundle/logs/container_vuln_1.log","filename":"container_vuln_1.log","size":3243,"category":"log"},{"path":"bundle/logs/result_vuln_1.json","filename":"result_vuln_1.json","size":466,"category":"other"},{"path":"bundle/logs/result_vuln_1_stderr.log","filename":"result_vuln_1_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/container_vuln_2.log","filename":"container_vuln_2.log","size":3243,"category":"log"},{"path":"bundle/logs/result_vuln_2.json","filename":"result_vuln_2.json","size":466,"category":"other"},{"path":"bundle/logs/result_vuln_2_stderr.log","filename":"result_vuln_2_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/container_fixed_1.log","filename":"container_fixed_1.log","size":3501,"category":"log"},{"path":"bundle/logs/result_fixed_1.json","filename":"result_fixed_1.json","size":398,"category":"other"},{"path":"bundle/logs/result_fixed_1_stderr.log","filename":"result_fixed_1_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/container_fixed_2.log","filename":"container_fixed_2.log","size":3527,"category":"log"},{"path":"bundle/logs/result_fixed_2.json","filename":"result_fixed_2.json","size":398,"category":"other"},{"path":"bundle/logs/result_fixed_2_stderr.log","filename":"result_fixed_2_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/proof_vuln_1.txt","filename":"proof_vuln_1.txt","size":73,"category":"other"},{"path":"bundle/logs/proof_vuln_2.txt","filename":"proof_vuln_2.txt","size":73,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":12112,"category":"log"},{"path":"bundle/logs/vuln_variant/claimed_fixed_image_identity.txt","filename":"claimed_fixed_image_identity.txt","size":406,"category":"other"},{"path":"bundle/logs/vuln_variant/followup_fixed_image_identity.txt","filename":"followup_fixed_image_identity.txt","size":409,"category":"other"},{"path":"bundle/logs/vuln_variant/container_claimed_fixed_1.log","filename":"container_claimed_fixed_1.log","size":3042,"category":"log"},{"path":"bundle/logs/vuln_variant/result_claimed_fixed_1.json","filename":"result_claimed_fixed_1.json","size":500,"category":"other"},{"path":"bundle/logs/vuln_variant/result_claimed_fixed_1_stderr.log","filename":"result_claimed_fixed_1_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/vuln_variant/container_claimed_fixed_2.log","filename":"container_claimed_fixed_2.log","size":3016,"category":"log"},{"path":"bundle/logs/vuln_variant/result_claimed_fixed_2.json","filename":"result_claimed_fixed_2.json","size":500,"category":"other"},{"path":"bundle/logs/vuln_variant/result_claimed_fixed_2_stderr.log","filename":"result_claimed_fixed_2_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/vuln_variant/container_followup_fixed_1.log","filename":"container_followup_fixed_1.log","size":91965,"category":"log"},{"path":"bundle/logs/vuln_variant/result_followup_fixed_1.json","filename":"result_followup_fixed_1.json","size":425,"category":"other"},{"path":"bundle/logs/vuln_variant/result_followup_fixed_1_stderr.log","filename":"result_followup_fixed_1_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/vuln_variant/container_followup_fixed_2.log","filename":"container_followup_fixed_2.log","size":91913,"category":"log"},{"path":"bundle/logs/vuln_variant/result_followup_fixed_2.json","filename":"result_followup_fixed_2.json","size":425,"category":"other"},{"path":"bundle/logs/vuln_variant/result_followup_fixed_2_stderr.log","filename":"result_followup_fixed_2_stderr.log","size":0,"category":"log"},{"path":"bundle/logs/vuln_variant/proof_claimed_fixed_1.txt","filename":"proof_claimed_fixed_1.txt","size":73,"category":"other"},{"path":"bundle/logs/vuln_variant/proof_claimed_fixed_2.txt","filename":"proof_claimed_fixed_2.txt","size":73,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":519,"category":"other"},{"path":"bundle/logs/vuln_variant/claimed_fixed_version.txt","filename":"claimed_fixed_version.txt","size":465,"category":"other"},{"path":"bundle/vuln_variant/variant_attempt.py","filename":"variant_attempt.py","size":9155,"category":"script"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1392,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":924,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1946,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5174,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":10923,"category":"documentation"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3346,"category":"other"}]}