# Patch Analysis — CVE-2026-55255 (Langflow IDOR in `get_flow_by_id_or_endpoint_name`)

## Fix under analysis

- **Fix commit:** `b0afe3d2d6506f3d69aff83325d46f0ec481e02a` (PR #12832,
  "fix(security): close IDOR in get_flow_by_id_or_endpoint_name (LE-639)",
  cherry-picked from `2c9f498d664a3c32698b57d7c5e752625291060e`).
- **Vulnerable parent:** `f52d0f0072dcb6968f147ba3a90a3c1ece79995b`
  (langflow 1.9.0 / langflow-base 0.9.0).
- **Files changed by the fix:**
  - `src/backend/base/langflow/helpers/flow.py` (the shared sink)
  - `src/backend/base/langflow/api/v1/endpoints.py` (new auth-aware wrappers + /run* swap)
  - `src/backend/tests/unit/helpers/test_flow_helpers.py` (unit tests)
  - `src/backend/tests/unit/test_endpoints.py` (integration tests)

## What the fix changes

### 1. The shared helper `get_flow_by_id_or_endpoint_name` (helpers/flow.py)

**Before (vulnerable, `f52d0f0072`):** the helper takes `user_id` but only the
endpoint-name branch consults it, and only when truthy. The UUID branch performs
a raw primary-key lookup with zero ownership check:

```python
async def get_flow_by_id_or_endpoint_name(flow_id_or_name, user_id=None):
    async with session_scope() as session:
        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 if 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()
        if flow is None:
            raise HTTPException(404, ...)
        return FlowRead.model_validate(flow, ...)
```

So any caller that supplies a UUID resolves *any* flow by primary key regardless
of the caller's `user_id`, and any caller that reaches the helper with a falsy
`user_id` (e.g. a FastAPI `Depends` that exposes `user_id` as an unset query
parameter) is unscoped on the endpoint-name branch too.

**After (fixed, `b0afe3d2d6`):** `user_id` is normalized once to a `UUID` at the
top (malformed values are converted into a 404, fail-closed), and the ownership
check is enforced on **both** branches. Cross-user lookups return `None` so the
shared 404 path fires (leak-safe: no 403-vs-404 existence oracle):

```python
uuid_user_id = None
if user_id is not None:
    try:
        uuid_user_id = UUID(user_id) if isinstance(user_id, str) else user_id
    except (ValueError, AttributeError) as exc:
        raise HTTPException(404, ...) from exc     # fail closed on bad user_id
try:
    flow_id = UUID(flow_id_or_name)
    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 UUID lookup -> 404
except ValueError:
    endpoint_name = flow_id_or_name
    stmt = select(Flow).where(Flow.endpoint_name == endpoint_name)
    if uuid_user_id is not None:
        stmt = stmt.where(Flow.user_id == uuid_user_id)
    flow = (await session.exec(stmt)).first()
if flow is None:
    raise HTTPException(404, ...)
```

### 2. The `/run*` routes (api/v1/endpoints.py)

**Before:** three routes used the bare helper as a FastAPI `Depends`:
`POST /api/v1/run/{flow_id_or_name}`, `POST /api/v1/run/session/{...}`, and
`POST /api/v1/run/advanced/{...}`. FastAPI exposed `user_id` as a plain query
parameter that no real caller sets, so the helper ran **unscoped** on those
routes; ownership was only enforced later by `check_flow_user_permission` in
`_run_flow_internal`, which raised 403 — leaving a 403-vs-404 existence oracle.

**After:** two auth-aware wrapper dependencies are added and the three `/run*`
routes are swapped to them:
- `get_flow_for_api_key_user(...)` pulls the caller from `api_key_security`
  (`x-api-key`) and forwards `api_key_user.id` to the helper.
- `get_flow_for_current_user(...)` pulls the caller from `CurrentActiveUser`
  (session auth) and forwards `current_user.id` to the helper.

Cross-user calls now fail closed at the helper layer (404) before execution.

### Behavior comparison

| Caller path | Vulnerable (`f52d0f0072`) | Fixed (`b0afe3d2d6`) |
|---|---|---|
| `POST /api/v1/responses` `{model: victimUUID}` (auth) | 200, executes victim flow, returns output | 404 `flow_not_found` |
| `POST /api/v2/workflows` `{flow_id: victimUUID}` (auth, developer API) | 200, executes victim flow, returns output (**this variant**) | 404 `FLOW_NOT_FOUND` |
| `POST /api/v1/run/{victimUUID}` (auth) | resolves victim flow, then 403 from `check_flow_user_permission` (existence oracle) | 404 at helper (no oracle) |
| `POST /api/v1/webhook/{victimUUID}` (public, by design) | 202, runs victim flow in background; output only via SSE (ownership-gated) | unchanged — intentionally public |
| `GET /api/v1/webhook-events/{victimUUID}` (auth) | resolves flow, then 403 (explicit `flow.user_id != user.id` check) | unchanged — already guarded |

## Assumptions the fix makes

1. **Every authenticated HTTP caller passes a real `user_id`.** The fix's
   invariant is: "callers that need cross-user access legitimately pass
   `user_id=None`, while callers that must scope to a user pass a real `user_id`."
   When `user_id is None`, both branches stay unscoped *by design* (preserved for
   webhooks and internal callers).
2. **The `/run*` routes no longer expose `user_id` as a query parameter.** The
   wrapper swap removes the unset-query-param footgun.
3. **`openai_responses.py` and `v2/workflow.py` already pass `user_id`
   explicitly**, so fixing the helper "auto-fixes" those endpoints (per the
   commit message). This is true: both pass `api_key_user.id`, and the fixed
   helper now honors it on the UUID branch.
4. **The webhook endpoint is intentionally public.** The fix deliberately keeps
   `/api/v1/webhook/{...}` on the bare helper (`user_id=None`) and the
   webhook-events route keeps its own explicit ownership check.

## What the fix does NOT cover (residual surfaces)

- **`user_id=None` is still a valid, unscoped path through the helper.** This is
  by design for webhooks/internal callers, but it means the *fix invariant* must
  hold everywhere: any *new* authenticated route that calls the helper without a
  real `user_id` (or that lets an attacker influence `user_id`) reintroduces the
  IDOR. The fix does not enforce this invariant mechanically (e.g. there is no
  "authenticated callers must pass user_id" guardrail).
- **The agentic MCP tool server** (`src/backend/base/langflow/agentic/mcp/server.py`,
  `FastMCP("langflow-agentic")`) exposes `@mcp.tool()` functions
  (`visualize_flow_graph`, `get_flow_component_details`, …) whose `user_id`
  parameter is **optional and defaults to `None`**, and which call
  `get_flow_by_id_or_endpoint_name(flow_id_or_name, user_id)`. If the tool client
  omits `user_id`, the helper runs unscoped and resolves any flow by UUID. This
  is a **stdio subprocess** (`python -m langflow.agentic.mcp`, auto-configured by
  `auto_configure_agentic_mcp_server`), not an HTTP endpoint, so it is a
  different trust boundary (the MCP client operator / agent flow owner), not a
  cross-tenant HTTP bypass of this CVE. Noted as a residual surface to harden.
- **The public webhook** (`POST /api/v1/webhook/{flow_id_or_name}`) still resolves
  and background-executes any flow by UUID with no ownership check. This is
  documented as by-design ("intentionally public"). Its impact is bounded: the
  caller receives only `202 Task started` and the flow output is delivered solely
  to SSE listeners, which are ownership-gated (`webhook-events` does
  `str(flow.user_id) != str(user.id)`). So the webhook yields **trigger/integrity
  impact (and resource consumption) but not output disclosure** to the caller.
  It is not a bypass of this CVE's authorization fix.

## Target threat model (read before claiming variants)

- `src/backend/base/langflow/.../SECURITY.md`-equivalent guidance: the fix commit
  message and code comments explicitly state the webhook is **intentionally
  public** and that `user_id=None` is preserved for webhooks/internal callers.
  Webhook cross-tenant triggering is therefore documented behavior, **not** a
  variant/bypass of this CVE.
- The CVE's threat model is an **authenticated** user executing **another user's**
  flow (cross-tenant authorization bypass). The variant in this report
  (`/api/v2/workflows`) crosses the **same** trust boundary (authenticated
  attacker via `x-api-key` → victim flow) and is closed by the same helper fix.

## Is the fix complete?

For the **authenticated HTTP API** surface, the fix is **complete**:
`/api/v1/responses`, `/api/v2/workflows`, `/api/v2/workflows/{job_id}` (status),
and the three `/api/v1/run*` routes all now scope flow resolution to the
authenticated caller. No authenticated HTTP route reaches the sink with an
attacker-controllable or unset `user_id` after the fix. The HTTP-mounted MCP
server (`/api/v1/mcp`, `Server("langflow-mcp-server")`) does not use this helper
at all — it resolves flows via `get_flow_snake_case(name, current_user.id, ...)`,
already scoped.

The residual `user_id=None` paths (public webhook, agentic stdio MCP tools) are
either documented-by-design or a different trust boundary, so they are **not**
bypasses of this fix. The variant demonstrated here (`/api/v2/workflows`) is an
**alternate trigger on the vulnerable version that the fix closes**, not a
bypass.

## Recommendation for the fix author

- The fix is sufficient for the current authenticated HTTP surface. To prevent
  regression, add a mechanical guardrail: make `get_flow_by_id_or_endpoint_name`
  **require** an explicit `user_id` for any caller that is not on an allowlist of
  public/internal entry points (webhook, internal callers), instead of relying on
  each caller to remember to pass it. This closes the "future route forgets
  `user_id`" class.
- For the agentic MCP tools, default `user_id` to the authenticated/agent-context
  user rather than `None`, so an agent cannot resolve another tenant's flow by
  UUID simply by omitting the argument.
