{"repro_id":"REPRO-2026-00268","version":6,"title":"Langflow’s /api/v1/responses endpoint contains an IDOR that lets any authenticated user execute another user’s flow by supplying the victim’s flow UUID.","repro_type":"security","status":"published","severity":"critical","cvss_score":9.9,"description":"An Insecure Direct Object Reference (IDOR) in Langflow’s `/api/v1/responses` endpoint allows an authenticated attacker to execute flows belonging to other users by providing the victim’s flow UUID as the `model` value. The vulnerable helper (`get_flow_by_id_or_endpoint_name`) queries by UUID without checking ownership.","root_cause":"# RCA Report — CVE-2026-55255\n\n## Summary\n\nLangflow's OpenAI-compatible `POST /api/v1/responses` endpoint contains an\nInsecure Direct Object Reference (IDOR). The helper\n`get_flow_by_id_or_endpoint_name` resolves a flow by UUID using\n`session.get(Flow, flow_id)` **without comparing `flow.user_id` to the caller's\nidentity**. Because `/api/v1/responses` passes the authenticated user's id as\nthe second argument but the UUID branch ignores it, any authenticated user can\nexecute any other user's flow by supplying the victim's flow UUID as the\nrequest `model` value. The fix (commit `b0afe3d2d6`, PR #12832) adds an\nownership check on both the UUID and endpoint-name branches and returns the\nflow as not-found when the caller is not the owner.\n\n## Impact\n\n- **Package/component affected:** `langflow-base` 0.9.0 (`langflow` 1.9.0),\n  helper `langflow.helpers.flow.get_flow_by_id_or_endpoint_name`, reached via\n  `langflow.api.v1.openai_responses.create_response` (`POST /api/v1/responses`).\n- **Affected versions:** Langflow `< 1.9.1` (advisory) / `< 1.9.2` (NVD). The\n  vulnerable source analyzed here is commit `f52d0f0072` (langflow 1.9.0 /\n  langflow-base 0.9.0), the parent of the fix commit `b0afe3d2d6`.\n- **Risk level and consequences:** High. An authenticated (non-superuser)\n  attacker can execute arbitrary flows owned by other tenants by UUID,\n  exposing those flows' outputs (cross-tenant data disclosure), consuming the\n  victims' resources, and affecting integrity. This is a cross-tenant\n  authorization bypass.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Authenticated cross-tenant flow\n  execution / IDOR (authorization bypass) on `/api/v1/responses`.\n- **Reproduced impact from this run:** An authenticated low-privileged attacker\n  executed a victim-owned flow through the real `POST /api/v1/responses`\n  endpoint by supplying the victim's flow UUID as `model`, receiving a\n  completed response whose `output` contained the flow's executed result. The\n  same request against the fixed build (commit `b0afe3d2d6`) was rejected with\n  `error.code = \"flow_not_found\"`.\n- **Parity:** `full` — the claimed IDOR (cross-tenant flow execution) was\n  reproduced end-to-end through the real HTTP API on a running Langflow 1.9.0\n  server, with a fixed-commit negative control.\n- **Not demonstrated:** No privilege escalation to superuser, no code execution\n  beyond running an existing flow, and no RCE. The proof demonstrates the\n  authorization bypass (flow execution + output disclosure), which is the\n  claimed impact.\n\n## Root Cause\n\n`get_flow_by_id_or_endpoint_name(flow_id_or_name, user_id)` has two branches:\n\n```python\n# vulnerable (commit f52d0f0072, langflow-base 0.9.0)\ntry:\n    flow_id = UUID(flow_id_or_name)\n    flow = await session.get(Flow, flow_id)     # <-- no user_id check\nexcept ValueError:\n    endpoint_name = flow_id_or_name\n    stmt = select(Flow).where(Flow.endpoint_name == endpoint_name)\n    if user_id:                                  # <-- only scoped when truthy\n        uuid_user_id = UUID(user_id) if isinstance(user_id, str) else user_id\n        stmt = stmt.where(Flow.user_id == uuid_user_id)\n    flow = (await session.exec(stmt)).first()\n```\n\nThe UUID branch calls `session.get(Flow, flow_id)` and returns the flow\nunconditionally — the `user_id` argument is never consulted. The\nendpoint-name branch only filters by `user_id` when a truthy value is passed.\n\n`POST /api/v1/responses` (`openai_responses.create_response`) authenticates the\ncaller via `api_key_security` (the `x-api-key` header) and then resolves the\nflow with the authenticated user's id:\n\n```python\nflow = await get_flow_by_id_or_endpoint_name(request.model, str(api_key_user.id))\n```\n\n`request.model` is attacker-controlled. When it parses as a UUID, the vulnerable\nUUID branch ignores `str(api_key_user.id)` and returns any flow with that UUID,\nregardless of ownership. The flow is then executed by `run_flow_for_openai_responses`,\nso the attacker runs the victim's flow and reads its output.\n\nThe fix (commit `b0afe3d2d6`, PR #12832, \"fix(security): close IDOR in\nget_flow_by_id_or_endpoint_name (LE-639)\") normalizes `user_id` once and\nenforces it on both branches:\n\n```python\nflow = await session.get(Flow, flow_id)\nif flow is not None and uuid_user_id is not None and flow.user_id != uuid_user_id:\n    flow = None      # cross-user lookup -> treated as not found (404)\n```\n\nso the shared `if flow is None: raise HTTPException(404)` fires for cross-tenant\nlookups and `/api/v1/responses` returns `error.code = \"flow_not_found\"`.\n\n**Fix commit:** `b0afe3d2d6` (PR #12832), file\n`src/backend/base/langflow/helpers/flow.py`.\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).\n2. **What it does:**\n   - Builds the **vulnerable** Langflow 1.9.0 stack (langflow-base 0.9.0) from\n     the repo source at commit `f52d0f0072` (`b0afe3d2d6^`). PyPI never\n     published langflow 1.9.0/1.9.1/1.9.2 (the 1.x series starts at 1.9.3 which\n     already contains the fix), so the vulnerable stack must be built from\n     source. The sandbox ships only Python 3.14 but langflow 1.9.0 requires\n     `<3.14`, so a standalone CPython 3.12 is provisioned via `uv`.\n   - Starts a real Langflow server (multi-user mode, SQLite) on\n     `127.0.0.1:7860`.\n   - Registers a **victim** and an **attacker** user via the public\n     `POST /api/v1/users/` signup endpoint (both `is_superuser: false`).\n   - Logs both in, mints API keys via `POST /api/v1/api_key/`.\n   - The victim creates a flow (ChatInput→ChatOutput echo, no LLM) and captures\n     the victim flow UUID (`VICTIM_FLOW_ID`).\n   - **Ownership proof:** `GET /api/v1/flows/{id}` returns `200` for the victim\n     and `404` for the attacker, confirming the flow is victim-owned and the\n     attacker has no legitimate access.\n   - **IDOR (vulnerable):** the attacker calls\n     `POST /api/v1/responses` with `x-api-key: <attacker>` and\n     `{\"model\": \"<VICTIM_FLOW_ID>\", \"input\": \"<probe>\", \"stream\": false}`.\n   - Swaps in the **fixed** `helpers/flow.py` from commit `b0afe3d2d6`,\n     restarts the server on the **same** SQLite DB (same flow/users), and\n     repeats the attacker call.\n3. **Expected evidence of reproduction:**\n   - Vulnerable build: HTTP 200, `object: \"response\"`, `status: \"completed\"`,\n     `model` equals the victim flow UUID, and `output[].content[].text`\n     contains the attacker's probe string — the victim's flow was executed by\n     the attacker.\n   - Fixed build: `{\"error\": {\"code\": \"flow_not_found\", \"message\": \"Flow with\n     id '<uuid>' not found\"}}` — the same request is blocked.\n   - The only difference between the two phases is `helpers/flow.py`, proving\n     the divergence is the missing ownership check.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full run log with version, marker\n  checks, and both IDOR responses.\n- `bundle/logs/langflow_server_vuln.log` / `bundle/logs/langflow_server_fixed.log`\n  — server startup/health logs for each phase.\n- `bundle/repro/artifacts/victim_register.json` / `attacker_register.json` —\n  signup responses (`is_superuser: false`).\n- `bundle/repro/artifacts/victim_flow_create.json` — flow creation response\n  containing `VICTIM_FLOW_ID` and `user_id` (victim).\n- `bundle/repro/artifacts/ownership_victim_get.txt` / `ownership_attacker_get.txt`\n  — `GET /api/v1/flows/{id}` status codes (200 vs 404).\n- `bundle/repro/artifacts/idor_response_vuln.json` — vulnerable IDOR response\n  (`status: completed`, probe in output).\n- `bundle/repro/artifacts/idor_response_fixed.json` — fixed IDOR response\n  (`error.code: flow_not_found`).\n- `bundle/repro/artifacts/summary.json` — structured classification\n  (`idor_confirmed: true`).\n- `bundle/repro/runtime_manifest.json` — runtime evidence manifest\n  (`service_started/healthcheck_passed/target_path_reached = true`).\n- `bundle/repro/validation_verdict.json` — structured verdict.\n\nEnvironment: Langflow 1.9.0 (langflow-base 0.9.0) built from source commit\n`f52d0f0072`; fixed control commit `b0afe3d2d6`; Python 3.12 (uv standalone);\nSQLite; uvicorn backend-only; multi-user mode (`LANGFLOW_AUTO_LOGIN=false`,\nsignup enabled).\n\nKey excerpt (vulnerable IDOR response):\n```json\n{\n  \"object\": \"response\",\n  \"status\": \"completed\",\n  \"error\": null,\n  \"model\": \"<VICTIM_FLOW_UUID>\",\n  \"output\": [{ \"type\": \"message\", \"status\": \"completed\",\n    \"content\": [{ \"type\": \"output_text\", \"text\": \"IDOR_PROBE_VULN_...\" }] }]\n}\n```\n\nKey excerpt (fixed IDOR response):\n```json\n{ \"error\": { \"message\": \"Flow with id '<VICTIM_FLOW_UUID>' not found\",\n  \"type\": \"invalid_request_error\", \"code\": \"flow_not_found\" } }\n```\n\n## Recommendations / Next Steps\n\n- **Upgrade** to Langflow >= 1.9.1 (contains the fix from PR #12832 /\n  commit `b0afe3d2d6`).\n- **Fix approach (already applied upstream):** enforce `user_id` on **both**\n  branches of `get_flow_by_id_or_endpoint_name` and return the flow as\n  not-found on cross-user lookups, so the shared 404 path fires and flow\n  existence is not disclosed to unauthorized callers.\n- **Defense in depth:** audit other callers of\n  `get_flow_by_id_or_endpoint_name` (e.g. webhook/run endpoints) for the same\n  ownership gap; add an integration test that asserts a cross-tenant UUID\n  lookup returns 404 (the upstream regression tests in\n  `tests/unit/helpers/test_flow_helpers.py` cover this).\n- **Testing recommendations:** add a multi-tenant API test that creates a flow\n  as user A and asserts user B cannot execute it via `/api/v1/responses` by\n  UUID.\n\n## Additional Notes\n\n- **Idempotency:** The script extracts the vulnerable source fresh each run\n  (so the editable `langflow-base` install always starts from the vulnerable\n  `flow.py`), reuses a cached venv at the durable project cache path when\n  present, and uses a fresh SQLite DB each run. Consecutive runs produce the\n  same vulnerable/completed vs fixed/flow_not_found divergence.\n- **Note on request field:** the advisory's example curl uses `\"input_value\"`,\n  but the actual `OpenAIResponsesRequest` schema field is `\"input\"`. The\n  reproduction uses the correct `input` field; the IDOR is independent of this\n  field (it is triggered by `model` = victim flow UUID).\n- **Note on HTTP status:** both the vulnerable (flow executed) and the fixed\n  (flow not found) responses are returned with HTTP 200 by the OpenAI-compatible\n  endpoint; the discriminator is the response **body** (`status: completed` +\n  `output` vs `error.code: flow_not_found`), which is a stronger signal than\n  the status code.\n- **Scope:** The vulnerable `wt-vuln`/`wt-fixed` worktrees provided in the\n  project cache already contained the fix commit, so the reproduction anchors\n  the vulnerable checkout to `b0afe3d2d6^` (`f52d0f0072`) per the fixed-commit\n  checkout rule, and the fixed checkout to `b0afe3d2d6`.\n","cve_id":"CVE-2026-55255","cwe_id":"CWE-639","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55255","package":{"name":"langflow (pip)","ecosystem":"pip","affected_versions":"< 1.9.1","fixed_version":">=1.9.1"},"reproduced_at":"2026-07-08T04:48:34.268372+00:00","duration_secs":1709.0,"tool_calls":241,"handoffs":2,"total_cost_usd":4.665756680000001,"agent_costs":{"judge":0.0124205,"repro":2.9722222200000004,"support":0.08697564,"vuln_variant":1.5941383199999997},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.0124205},"repro":{"accounts/fireworks/routers/glm-5p2-fast":2.9722222200000004},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.08697564},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.5941383199999997}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-08T04:49:10.390291+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":24153,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10863,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":24588,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":13416,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":15834,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":22020,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2473,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2551,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1081,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1012,"category":"other"},{"path":"bundle/repro/artifacts/idor_response_vuln.json","filename":"idor_response_vuln.json","size":718,"category":"other"},{"path":"bundle/repro/artifacts/idor_response_fixed.json","filename":"idor_response_fixed.json","size":141,"category":"other"},{"path":"bundle/repro/artifacts/summary.json","filename":"summary.json","size":386,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":3365,"category":"log"},{"path":"bundle/logs/langflow_server_vuln.log","filename":"langflow_server_vuln.log","size":3302,"category":"log"},{"path":"bundle/logs/langflow_server_fixed.log","filename":"langflow_server_fixed.log","size":2523,"category":"log"},{"path":"bundle/repro/artifacts/victim_register.json","filename":"victim_register.json","size":332,"category":"other"},{"path":"bundle/repro/artifacts/attacker_register.json","filename":"attacker_register.json","size":334,"category":"other"},{"path":"bundle/repro/artifacts/victim_flow_create.json","filename":"victim_flow_create.json","size":21950,"category":"other"},{"path":"bundle/repro/artifacts/ownership_victim_get.txt","filename":"ownership_victim_get.txt","size":62,"category":"other"},{"path":"bundle/repro/artifacts/ownership_attacker_get.txt","filename":"ownership_attacker_get.txt","size":64,"category":"other"},{"path":"bundle/vuln_variant/artifacts/variant_response_vuln.json","filename":"variant_response_vuln.json","size":446,"category":"other"},{"path":"bundle/vuln_variant/artifacts/variant_response_fixed.json","filename":"variant_response_fixed.json","size":232,"category":"other"},{"path":"bundle/vuln_variant/artifacts/summary.json","filename":"summary.json","size":763,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":14182,"category":"log"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1458,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5158,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":10357,"category":"documentation"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1297,"category":"other"},{"path":"bundle/vuln_variant/artifacts/ownership_victim_get.txt","filename":"ownership_victim_get.txt","size":62,"category":"other"},{"path":"bundle/vuln_variant/artifacts/victim_flow_create.json","filename":"victim_flow_create.json","size":21956,"category":"other"},{"path":"bundle/logs/vuln_variant/langflow_server_vuln.log","filename":"langflow_server_vuln.log","size":3712,"category":"log"},{"path":"bundle/logs/vuln_variant/langflow_server_fixed.log","filename":"langflow_server_fixed.log","size":2523,"category":"log"},{"path":"bundle/vuln_variant/artifacts/victim_register.json","filename":"victim_register.json","size":334,"category":"other"},{"path":"bundle/vuln_variant/artifacts/attacker_register.json","filename":"attacker_register.json","size":336,"category":"other"},{"path":"bundle/vuln_variant/artifacts/ownership_attacker_get.txt","filename":"ownership_attacker_get.txt","size":64,"category":"other"}]}