# RCA Report — CVE-2026-55255

## Summary

Langflow's OpenAI-compatible `POST /api/v1/responses` endpoint contains an
Insecure Direct Object Reference (IDOR). The helper
`get_flow_by_id_or_endpoint_name` resolves a flow by UUID using
`session.get(Flow, flow_id)` **without comparing `flow.user_id` to the caller's
identity**. Because `/api/v1/responses` passes the authenticated user's id as
the second argument but the UUID branch ignores it, any authenticated user can
execute any other user's flow by supplying the victim's flow UUID as the
request `model` value. The fix (commit `b0afe3d2d6`, PR #12832) adds an
ownership check on both the UUID and endpoint-name branches and returns the
flow as not-found when the caller is not the owner.

## Impact

- **Package/component affected:** `langflow-base` 0.9.0 (`langflow` 1.9.0),
  helper `langflow.helpers.flow.get_flow_by_id_or_endpoint_name`, reached via
  `langflow.api.v1.openai_responses.create_response` (`POST /api/v1/responses`).
- **Affected versions:** Langflow `< 1.9.1` (advisory) / `< 1.9.2` (NVD). The
  vulnerable source analyzed here is commit `f52d0f0072` (langflow 1.9.0 /
  langflow-base 0.9.0), the parent of the fix commit `b0afe3d2d6`.
- **Risk level and consequences:** High. An authenticated (non-superuser)
  attacker can execute arbitrary flows owned by other tenants by UUID,
  exposing those flows' outputs (cross-tenant data disclosure), consuming the
  victims' resources, and affecting integrity. This is a cross-tenant
  authorization bypass.

## Impact Parity

- **Disclosed/claimed maximum impact:** Authenticated cross-tenant flow
  execution / IDOR (authorization bypass) on `/api/v1/responses`.
- **Reproduced impact from this run:** An authenticated low-privileged attacker
  executed a victim-owned flow through the real `POST /api/v1/responses`
  endpoint by supplying the victim's flow UUID as `model`, receiving a
  completed response whose `output` contained the flow's executed result. The
  same request against the fixed build (commit `b0afe3d2d6`) was rejected with
  `error.code = "flow_not_found"`.
- **Parity:** `full` — the claimed IDOR (cross-tenant flow execution) was
  reproduced end-to-end through the real HTTP API on a running Langflow 1.9.0
  server, with a fixed-commit negative control.
- **Not demonstrated:** No privilege escalation to superuser, no code execution
  beyond running an existing flow, and no RCE. The proof demonstrates the
  authorization bypass (flow execution + output disclosure), which is the
  claimed impact.

## Root Cause

`get_flow_by_id_or_endpoint_name(flow_id_or_name, user_id)` has two branches:

```python
# vulnerable (commit f52d0f0072, langflow-base 0.9.0)
try:
    flow_id = UUID(flow_id_or_name)
    flow = await session.get(Flow, flow_id)     # <-- no user_id check
except ValueError:
    endpoint_name = flow_id_or_name
    stmt = select(Flow).where(Flow.endpoint_name == endpoint_name)
    if user_id:                                  # <-- only scoped when truthy
        uuid_user_id = UUID(user_id) if isinstance(user_id, str) else user_id
        stmt = stmt.where(Flow.user_id == uuid_user_id)
    flow = (await session.exec(stmt)).first()
```

The UUID branch calls `session.get(Flow, flow_id)` and returns the flow
unconditionally — the `user_id` argument is never consulted. The
endpoint-name branch only filters by `user_id` when a truthy value is passed.

`POST /api/v1/responses` (`openai_responses.create_response`) authenticates the
caller via `api_key_security` (the `x-api-key` header) and then resolves the
flow with the authenticated user's id:

```python
flow = await get_flow_by_id_or_endpoint_name(request.model, str(api_key_user.id))
```

`request.model` is attacker-controlled. When it parses as a UUID, the vulnerable
UUID branch ignores `str(api_key_user.id)` and returns any flow with that UUID,
regardless of ownership. The flow is then executed by `run_flow_for_openai_responses`,
so the attacker runs the victim's flow and reads its output.

The fix (commit `b0afe3d2d6`, PR #12832, "fix(security): close IDOR in
get_flow_by_id_or_endpoint_name (LE-639)") normalizes `user_id` once and
enforces it on both branches:

```python
flow = await session.get(Flow, flow_id)
if flow is not None and uuid_user_id is not None and flow.user_id != uuid_user_id:
    flow = None      # cross-user lookup -> treated as not found (404)
```

so the shared `if flow is None: raise HTTPException(404)` fires for cross-tenant
lookups and `/api/v1/responses` returns `error.code = "flow_not_found"`.

**Fix commit:** `b0afe3d2d6` (PR #12832), file
`src/backend/base/langflow/helpers/flow.py`.

## Reproduction Steps

1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).
2. **What it does:**
   - Builds the **vulnerable** Langflow 1.9.0 stack (langflow-base 0.9.0) from
     the repo source at commit `f52d0f0072` (`b0afe3d2d6^`). PyPI never
     published langflow 1.9.0/1.9.1/1.9.2 (the 1.x series starts at 1.9.3 which
     already contains the fix), so the vulnerable stack must be built from
     source. The sandbox ships only Python 3.14 but langflow 1.9.0 requires
     `<3.14`, so a standalone CPython 3.12 is provisioned via `uv`.
   - Starts a real Langflow server (multi-user mode, SQLite) on
     `127.0.0.1:7860`.
   - Registers a **victim** and an **attacker** user via the public
     `POST /api/v1/users/` signup endpoint (both `is_superuser: false`).
   - Logs both in, mints API keys via `POST /api/v1/api_key/`.
   - The victim creates a flow (ChatInput→ChatOutput echo, no LLM) and captures
     the victim flow UUID (`VICTIM_FLOW_ID`).
   - **Ownership proof:** `GET /api/v1/flows/{id}` returns `200` for the victim
     and `404` for the attacker, confirming the flow is victim-owned and the
     attacker has no legitimate access.
   - **IDOR (vulnerable):** the attacker calls
     `POST /api/v1/responses` with `x-api-key: <attacker>` and
     `{"model": "<VICTIM_FLOW_ID>", "input": "<probe>", "stream": false}`.
   - Swaps in the **fixed** `helpers/flow.py` from commit `b0afe3d2d6`,
     restarts the server on the **same** SQLite DB (same flow/users), and
     repeats the attacker call.
3. **Expected evidence of reproduction:**
   - Vulnerable build: HTTP 200, `object: "response"`, `status: "completed"`,
     `model` equals the victim flow UUID, and `output[].content[].text`
     contains the attacker's probe string — the victim's flow was executed by
     the attacker.
   - Fixed build: `{"error": {"code": "flow_not_found", "message": "Flow with
     id '<uuid>' not found"}}` — the same request is blocked.
   - The only difference between the two phases is `helpers/flow.py`, proving
     the divergence is the missing ownership check.

## Evidence

- `bundle/logs/reproduction_steps.log` — full run log with version, marker
  checks, and both IDOR responses.
- `bundle/logs/langflow_server_vuln.log` / `bundle/logs/langflow_server_fixed.log`
  — server startup/health logs for each phase.
- `bundle/repro/artifacts/victim_register.json` / `attacker_register.json` —
  signup responses (`is_superuser: false`).
- `bundle/repro/artifacts/victim_flow_create.json` — flow creation response
  containing `VICTIM_FLOW_ID` and `user_id` (victim).
- `bundle/repro/artifacts/ownership_victim_get.txt` / `ownership_attacker_get.txt`
  — `GET /api/v1/flows/{id}` status codes (200 vs 404).
- `bundle/repro/artifacts/idor_response_vuln.json` — vulnerable IDOR response
  (`status: completed`, probe in output).
- `bundle/repro/artifacts/idor_response_fixed.json` — fixed IDOR response
  (`error.code: flow_not_found`).
- `bundle/repro/artifacts/summary.json` — structured classification
  (`idor_confirmed: true`).
- `bundle/repro/runtime_manifest.json` — runtime evidence manifest
  (`service_started/healthcheck_passed/target_path_reached = true`).
- `bundle/repro/validation_verdict.json` — structured verdict.

Environment: Langflow 1.9.0 (langflow-base 0.9.0) built from source commit
`f52d0f0072`; fixed control commit `b0afe3d2d6`; Python 3.12 (uv standalone);
SQLite; uvicorn backend-only; multi-user mode (`LANGFLOW_AUTO_LOGIN=false`,
signup enabled).

Key excerpt (vulnerable IDOR response):
```json
{
  "object": "response",
  "status": "completed",
  "error": null,
  "model": "<VICTIM_FLOW_UUID>",
  "output": [{ "type": "message", "status": "completed",
    "content": [{ "type": "output_text", "text": "IDOR_PROBE_VULN_..." }] }]
}
```

Key excerpt (fixed IDOR response):
```json
{ "error": { "message": "Flow with id '<VICTIM_FLOW_UUID>' not found",
  "type": "invalid_request_error", "code": "flow_not_found" } }
```

## Recommendations / Next Steps

- **Upgrade** to Langflow >= 1.9.1 (contains the fix from PR #12832 /
  commit `b0afe3d2d6`).
- **Fix approach (already applied upstream):** enforce `user_id` on **both**
  branches of `get_flow_by_id_or_endpoint_name` and return the flow as
  not-found on cross-user lookups, so the shared 404 path fires and flow
  existence is not disclosed to unauthorized callers.
- **Defense in depth:** audit other callers of
  `get_flow_by_id_or_endpoint_name` (e.g. webhook/run endpoints) for the same
  ownership gap; add an integration test that asserts a cross-tenant UUID
  lookup returns 404 (the upstream regression tests in
  `tests/unit/helpers/test_flow_helpers.py` cover this).
- **Testing recommendations:** add a multi-tenant API test that creates a flow
  as user A and asserts user B cannot execute it via `/api/v1/responses` by
  UUID.

## Additional Notes

- **Idempotency:** The script extracts the vulnerable source fresh each run
  (so the editable `langflow-base` install always starts from the vulnerable
  `flow.py`), reuses a cached venv at the durable project cache path when
  present, and uses a fresh SQLite DB each run. Consecutive runs produce the
  same vulnerable/completed vs fixed/flow_not_found divergence.
- **Note on request field:** the advisory's example curl uses `"input_value"`,
  but the actual `OpenAIResponsesRequest` schema field is `"input"`. The
  reproduction uses the correct `input` field; the IDOR is independent of this
  field (it is triggered by `model` = victim flow UUID).
- **Note on HTTP status:** both the vulnerable (flow executed) and the fixed
  (flow not found) responses are returned with HTTP 200 by the OpenAI-compatible
  endpoint; the discriminator is the response **body** (`status: completed` +
  `output` vs `error.code: flow_not_found`), which is a stronger signal than
  the status code.
- **Scope:** The vulnerable `wt-vuln`/`wt-fixed` worktrees provided in the
  project cache already contained the fix commit, so the reproduction anchors
  the vulnerable checkout to `b0afe3d2d6^` (`f52d0f0072`) per the fixed-commit
  checkout rule, and the fixed checkout to `b0afe3d2d6`.
