# Variant RCA Report — CVE-2026-55255

## Summary

This report documents a **validated alternate trigger** of CVE-2026-55255 — the
same Insecure Direct Object Reference (IDOR) in Langflow's
`get_flow_by_id_or_endpoint_name`, reached through a **materially different
entry point**: the v2 Developer API endpoint `POST /api/v2/workflows` (router
prefix `/workflows`, mounted at `/api/v2`). Its handler `execute_workflow`
calls `get_flow_by_id_or_endpoint_name(workflow_request.flow_id,
api_key_user.id)` and then **executes** the resolved flow with **no**
`check_flow_user_permission`. On the vulnerable helper (`f52d0f0072`,
langflow 1.9.0) the UUID branch ignores `api_key_user.id`, so an authenticated
attacker runs a victim's workflow by supplying the victim's flow UUID as
`flow_id` and reads the executed output — the same cross-tenant authorization
bypass and impact class as the disclosed `/api/v1/responses` IDOR, via a
different endpoint and request shape.

This is **not a bypass**: the released fix (commit `b0afe3d2d6`, PR #12832)
makes the helper enforce `flow.user_id != uuid_user_id -> None` on the UUID
branch, so the identical `/api/v2/workflows` request returns `404
FLOW_NOT_FOUND` on the fixed code. The variant was reproduced on the vulnerable
build and confirmed blocked on the fixed build, on the same DB/flow/users,
swapping only `helpers/flow.py` between phases. A bounded search for a true
bypass (a path that survives the fix) found none on the authenticated HTTP
surface; the only residual `user_id=None` paths (public webhook, agentic stdio
MCP tools) are documented-by-design or a different trust boundary.

## Fix Coverage / Assumptions

- **Invariant the fix relies on:** every authenticated HTTP caller passes a
  real `user_id` to the helper; `user_id=None` is reserved for webhooks and
  internal callers that legitimately need cross-user lookup.
- **Code paths the fix explicitly covers:**
  - `helpers/flow.py::get_flow_by_id_or_endpoint_name` — ownership enforced on
    both the UUID and endpoint-name branches; bad `user_id` -> 404 (fail-closed).
  - `api/v1/endpoints.py` — new wrappers `get_flow_for_api_key_user` /
    `get_flow_for_current_user`; the three `/run*` routes swapped off the bare
    `Depends` helper.
  - `api/v1/openai_responses.py::create_response` and
    `api/v2/workflow.py::execute_workflow` / `get_workflow_status` — already
    pass `api_key_user.id` explicitly, so the helper fix auto-closes them.
- **What the fix does NOT cover:** it does not *mechanically* require
  `user_id` for authenticated callers (a future route that forgets to pass it
  reintroduces the IDOR). The public webhook and the agentic stdio MCP tools
  keep `user_id=None` (documented-by-design / different trust boundary). See
  `bundle/vuln_variant/patch_analysis.md`.

## Variant / Alternate Trigger

**Entry point:** `POST /api/v2/workflows` (Developer API; requires
`LANGFLOW_DEVELOPER_API_ENABLED=true`, a supported setting:
"enable developer API endpoints for advanced debugging and introspection").

**Request shape (materially different from the parent):**
```json
{ "flow_id": "<VICTIM_FLOW_UUID>", "inputs": {"ChatInput-<id>.input_value": "<probe>"} }
```
with the attacker's own `x-api-key` header. (Parent used `POST /api/v1/responses`
with `{"model": victimUUID, "input": ...}`.)

**Code path:**
- `src/backend/base/langflow/api/v2/workflow.py::execute_workflow`
  (line 151): `flow = await get_flow_by_id_or_endpoint_name(workflow_request.flow_id, api_key_user.id)`
- vulnerable helper `src/backend/base/langflow/helpers/flow.py` UUID branch:
  `flow = await session.get(Flow, flow_id)` — ignores `api_key_user.id`
- `execute_sync_workflow` builds the graph with `user_id = str(api_key_user.id)`
  (the **attacker's** id) and runs the **victim's** flow, returning its outputs
  to the attacker. There is **no** `check_flow_user_permission` call on this
  path (unlike the v1 `/run*` routes), so on the vulnerable version this is a
  full execution IDOR, not merely an existence oracle.

**Bounded search for a bypass (paths that survive the fix):**
- `POST /api/v1/webhook/{victimUUID}` — resolves + background-runs any flow by
  UUID with `user_id=None`. **Documented as intentionally public** by the fix
  author; caller receives only `202`, output is delivered to ownership-gated SSE
  listeners. Not a bypass (by-design, different trust boundary).
- `GET /api/v1/webhook-events/{victimUUID}` — explicit
  `str(flow.user_id) != str(user.id)` check. Already guarded.
- `POST /api/v1/run/{victimUUID}` (+ `/session`, `/advanced`) — on vulnerable,
  bare `Depends` helper (`user_id=None`) resolves the flow, then
  `check_flow_user_permission` blocks with 403 (existence oracle only, no
  execution). Fix swaps these to auth-aware wrappers (now 404). Alternate
  trigger, closed by the fix, **lower impact** (info disclosure, not execution).
- HTTP-mounted `Server("langflow-mcp-server")` at `/api/v1/mcp` — does not use
  this helper (uses `get_flow_snake_case(name, current_user.id, ...)`). Not
  affected.
- Agentic `FastMCP("langflow-agentic")` tools — stdio subprocess, optional
  `user_id=None`; different trust boundary (MCP client/agent owner), not a
  cross-tenant HTTP bypass. Residual hardening surface, not a bypass.

No authenticated HTTP path reaches the sink with an attacker-controllable or
unset `user_id` after the fix. **No bypass found.**

## 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.v2.workflow.execute_workflow` (`POST /api/v2/workflows`,
  Developer API).
- **Affected versions (as tested):** vulnerable at commit `f52d0f0072`
  (langflow 1.9.0); fix at commit `b0afe3d2d6` (PR #12832). Advisory: `<1.9.1`;
  NVD: `<1.9.2`. Requires `developer_api_enabled=true` for this variant's entry
  point (non-default but supported configuration).
- **Risk level and consequences:** High. An authenticated (non-superuser)
  attacker executes arbitrary flows owned by other tenants by UUID and reads
  their outputs (cross-tenant data disclosure + integrity + resource
  consumption). Same impact class as the parent CVE.

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** authenticated cross-tenant
  flow execution / IDOR (authorization bypass) returning the victim flow's
  output.
- **Reproduced impact from this variant run:** an authenticated attacker
  executed a victim-owned flow through the real `POST /api/v2/workflows`
  endpoint by supplying the victim's flow UUID as `flow_id`, receiving a
  `WorkflowExecutionResponse` with `status:"completed"` whose
  `outputs.ChatOutput-J6aor.content` contained an **attacker-injected probe**
  string — i.e. the victim's flow ran with attacker-controlled input and the
  output was returned to the attacker. The same request on the fixed build
  returns `404 FLOW_NOT_FOUND`.
- **Parity: `full`.** The variant reaches the same sink, crosses the same
  trust boundary, and yields the same authorization-bypass + output-disclosure
  impact as the parent, via a different endpoint.
- **Not demonstrated:** no code execution / memory primitive is claimed or
  needed (this is an authorization/IDOR issue).

## Root Cause

The shared helper `get_flow_by_id_or_endpoint_name` performed a raw
`session.get(Flow, flow_id)` on the UUID branch **without comparing
`flow.user_id` to the caller's `user_id`**. `/api/v2/workflows::execute_workflow`
passes `api_key_user.id` (the authenticated caller) as the second argument, but
the vulnerable UUID branch discards it, so any authenticated user resolves any
flow by primary key and `execute_sync_workflow` runs it. The fix normalizes
`user_id` once and enforces `flow.user_id != uuid_user_id -> None` on both
branches, so cross-user UUID lookups fall through to the shared 404 path. The
same single-helper change that closes the parent `/api/v1/responses` IDOR also
closes this `/api/v2/workflows` variant (the maintainers note in the commit
message that "openai_responses.py and v2/workflow.py pass user_id explicitly,
so fixing the helper auto-fixes those endpoints").

**Fix commit:** `b0afe3d2d6506f3d69aff83325d46f0ec481e02a` (PR #12832).

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh` (self-contained,
   idempotent; reuses the prepared project cache's venv + source).
2. **What it does:**
   - Extracts the vulnerable source `f52d0f0072`, ensures the vulnerable
     `helpers/flow.py` is installed (editable), and starts a real Langflow 1.9.0
     server (multi-user, SQLite) on `127.0.0.1:7861` with
     `LANGFLOW_DEVELOPER_API_ENABLED=true`.
   - Registers a **victim** and an **attacker** via the public signup endpoint
     (both `is_superuser:false`), logs both in, mints API keys.
   - Victim creates a flow (ChatInput→ChatOutput echo, no LLM) and the script
     captures `VICTIM_FLOW_ID` + the ChatInput node id.
   - **Ownership proof:** `GET /api/v1/flows/{id}` → victim 200, attacker 404
     (attacker has no legitimate access).
   - **Variant (vulnerable):** attacker `POST /api/v2/workflows`
     `{flow_id: VICTIM_FLOW_ID, inputs: {ChatInput-<id>.input_value: <probe>}}`
     with the attacker's `x-api-key`.
   - Swaps in the fixed `helpers/flow.py` (`b0afe3d2d6`), restarts on the **same**
     SQLite DB (same flow/users), re-logs in the attacker, and repeats the
     identical request. Restores the vulnerable `flow.py` at the end.
3. **Expected evidence of reproduction:**
   - Vulnerable: HTTP 200, `object:"response"`, `status:"completed"`, and the
     attacker-injected probe in `outputs.ChatOutput-J6aor.content` — the victim's
     flow executed by the attacker. *(Observed: probe
     `IDOR_VARIANT_PROBE_V2WF_...` in the response.)*
   - Fixed: HTTP 404 `{"detail":{"error":"Flow not found","code":"FLOW_NOT_FOUND",...}}`.
     *(Observed.)*

## Evidence

- `bundle/vuln_variant/artifacts/variant_response_vuln.json` — vulnerable IDOR
  response (`status:"completed"`, probe in `outputs.ChatOutput-J6aor.content`).
- `bundle/vuln_variant/artifacts/variant_response_fixed.json` — fixed response
  (`http_status=404`, `code:"FLOW_NOT_FOUND"`).
- `bundle/vuln_variant/artifacts/ownership_victim_get.txt` /
  `ownership_attacker_get.txt` — 200 vs 404 ownership proof.
- `bundle/vuln_variant/artifacts/victim_flow_create.json` — victim flow +
  `VICTIM_FLOW_ID` + `user_id` (victim).
- `bundle/vuln_variant/artifacts/victim_register.json` /
  `attacker_register.json` — `is_superuser:false` signups.
- `bundle/vuln_variant/artifacts/summary.json` — structured classification.
- `bundle/vuln_variant/runtime_manifest.json` — runtime evidence manifest.
- `bundle/vuln_variant/validation_verdict.json` — structured verdict.
- `bundle/logs/vuln_variant/reproduction_steps.log` — full run log.
- `bundle/logs/vuln_variant/langflow_server_vuln.log` /
  `langflow_server_fixed.log` — server startup/health per phase.

Environment: Langflow 1.9.0 (langflow-base 0.9.0) built from source at
`f52d0f0072`; fixed phase at `b0afe3d2d6`; SQLite; uvicorn; Python 3.12 (via uv);
`LANGFLOW_DEVELOPER_API_ENABLED=true`.

## Recommendations / Next Steps

- **No fix gap for the authenticated HTTP surface.** The released fix
  (`b0afe3d2d6`) closes this variant. Coders should treat
  `/api/v2/workflows` as a co-affected endpoint of the same CVE (it is already
  fixed by the helper change; verify the deployed version is `>=1.9.1`/`>=1.9.2`).
- **Regression guardrail:** make `get_flow_by_id_or_endpoint_name` *require* an
  explicit `user_id` for any caller not on a public/internal allowlist, instead
  of trusting every caller to pass it. Prevents a future route from reintroducing
  the IDOR by forgetting `user_id`.
- **Agentic MCP tools:** default the optional `user_id` argument to the
  authenticated/agent-context user instead of `None`, so an agent cannot resolve
  another tenant's flow by UUID by omitting the argument.
- **Public webhook:** consider per-flow webhook opt-in so an unauthenticated
  caller cannot trigger arbitrary flows by UUID (separate from this CVE's
  authenticated authorization model, but worth hardening).

## Additional Notes

- **Idempotency:** the script was run three times end-to-end; all runs
  reproduced the variant on the vulnerable build (`status:"completed"`, probe in
  output) and confirmed the block on the fixed build (`404 FLOW_NOT_FOUND`),
  with `SCRIPT_EXIT_CODE=1` (alternate trigger, not a bypass) each time. The
  script restores the vulnerable `flow.py` on exit so the project cache is left
  as the repro stage expects.
- **Exit-code semantics:** `0` would mean a true bypass (reproduced on fixed);
  `1` = alternate trigger reproduced on vulnerable only / no bypass; `2` =
  infrastructure failure. This run is `1`.
- **Caveat:** the `/api/v2/workflows` entry point requires
  `developer_api_enabled=true` (default `false`). This is a supported,
  documented configuration, so the variant is valid; deployments with the
  Developer API disabled are not exposed via this specific endpoint (but the
  `/api/v1/responses` parent IDOR affects them on the vulnerable version
  regardless).
- **Out of scope ruled out:** the public webhook and agentic stdio MCP tools are
  not claimed as variants (documented-by-design / different trust boundary).
