# Patch Analysis — CVE-2026-33017 (Langflow public flow build RCE)

## Scope and method

This analysis compares the vulnerable version (`langflowai/langflow:1.8.1`,
git tag `v1.9.0`-parent / `v1.8.1` → commit `0a6f7e015b`), the CVE's stated
**fixed** version (`langflowai/langflow:1.9.0` → commit `a47f2ad17e`), and the
**follow-up fixed** version (`langflowai/langflow:1.10.1` → commit `a66b75ac26`)
which is the version that actually closes the bypass found here. All source
inspection was done read-only from the project repo mirror
(`/data/pruva/project-cache/.../repo-mirrors/langflow`); no checkout state was
mutated.

## What the v1.9.0 fix changes

The fix is concentrated in `src/backend/base/langflow/api/v1/chat.py` (the
`build_public_tmp` and `build_flow` endpoints) plus a new helper module
`src/lfx/src/lfx/utils/flow_validation.py`.

### `build_public_tmp` (the unauthenticated public build endpoint)

Before (`v1.8.1`):

```python
async def build_public_tmp(..., data: Annotated[FlowDataRequest | None, Body(embed=True)] = None, ...):
    ...
    owner_user, new_flow_id = await verify_public_flow_and_get_user(flow_id=flow_id, client_id=client_id)
    job_id = await start_flow_build(flow_id=new_flow_id, ..., data=data, ...)   # attacker `data` flows in
```

After (`v1.9.0`, `chat.py:640-725`):

1. The `data` parameter is **removed** from the signature (`chat.py:644` no
   longer declares `data`).
2. A `source_flow_id=flow_id` argument is added and `data=None` is hardcoded in
   the `start_flow_build(...)` call (`chat.py:717`, `chat.py:720`):
   ```python
   job_id = await start_flow_build(
       flow_id=new_flow_id,
       source_flow_id=flow_id,        # load the real flow from the DB
       ...,
       data=None,                     # Always None - public flows load from database only
       ...,
   )
   ```
   With `data=None` + `source_flow_id`, `generate_flow_events` → `create_graph`
   takes the `build_graph_from_db(flow_id=db_flow_id)` branch
   (`src/backend/base/langflow/api/build.py:305-313`), i.e. the flow definition
   is loaded from the database, not from the request body.
3. A validation gate is added on the **stored** flow data
   (`chat.py:708-711`):
   ```python
   async with session_scope() as session:
       flow = await session.get(Flow, flow_id)
       if flow and flow.data:
           validate_flow_for_current_settings(flow.data)
   ```
4. `CustomComponentValidationError` is mapped to HTTP 400
   `"This flow cannot be executed."` (`chat.py:722-724`).
5. `build_public_tmp/{job_id}/events` and `/cancel` public endpoints were added.

### `build_flow` (the authenticated build endpoint)

`v1.9.0` also added `validate_flow_for_current_settings(data.model_dump())` on
the client-supplied `data` (`chat.py:221`) and on the stored `flow.data`
(`chat.py:223`), plus an ownership/404 check and job-owner registration. This
endpoint still **accepts** a `data` parameter.

### `validate_flow_for_current_settings` / `check_flow_and_raise`

New file `src/lfx/src/lfx/utils/flow_validation.py`. The effective gate is:

```python
# flow_validation.py:158-166
def check_flow_and_raise(flow_data, *, allow_custom_components, type_to_current_hash=None):
    if allow_custom_components or not flow_data:   # <-- early return
        return
    ...
    raise CustomComponentValidationError(...)       # only when allow_custom_components is False
```

and:

```python
# flow_validation.py:206-216
def validate_flow_for_current_settings(target):
    ...
    allow_custom_components = settings_service.settings.allow_custom_components
    ...
    check_flow_and_raise(normalized_flow_data, allow_custom_components=allow_custom_components, ...)
```

## What assumptions the fix makes

1. **"Public flows never accept client-supplied data."** Removing the `data`
   parameter assumes the only way to inject a malicious flow definition onto the
   public build path is the request body.
2. **"The stored flow is benign."** The fix assumes the flow stored in the
   database was authored by a trusted user and contains no attacker-controlled
   custom-component code.
3. **`validate_flow_for_current_settings` enforces a custom-component policy.**
   The fix relies on this gate to block malicious custom components stored in a
   public flow. **This is the flawed assumption** — see below.
4. **`allow_custom_components` is treated as the policy knob.** The gate is a
   no-op whenever `allow_custom_components` is `True`.

## What the fix does NOT cover (the gap)

`allow_custom_components` defaults to **`True`**
(`src/lfx/src/lfx/services/settings/base.py:386` in v1.9.0; confirmed at runtime
because the bypass produced `exploit_status=200`, not `400`). When it is `True`,
`check_flow_and_raise` returns immediately (`flow_validation.py:165`) and
`validate_flow_for_current_settings` is a **no-op**. Therefore, on the v1.9.0
public build path:

* The client can no longer supply `data` (closed), **but**
* the server still loads the **stored** public flow from the DB and **exec()'s
  each node's stored custom-component `code`** at graph-build time via
  `prepare_global_scope()`/`eval_custom_component_code`
  (`src/lfx/src/lfx/custom/validate.py:218-222`), and
* the only gate that could stop it is a no-op under the default configuration.

An attacker who can **create a PUBLIC flow whose stored `data` carries a
malicious CustomComponent** therefore still gets unauthenticated RCE on the
public build path. Creating the flow uses `POST /api/v1/flows/`
(`src/backend/base/langflow/api/v1/flows.py:86` `_new_flow`) which performs
**no** custom-component validation in v1.9.0, and is reachable with the
`AUTO_LOGIN` superuser token (no credentials) — the exact capability the
original CVE reproduction already uses to create its public flow.

This is not a hypothetical gap: the upstream maintainers themselves issued a
follow-up commit to close it:

> `626365f088` **fix(security): run trusted server code on unauthenticated
> public flow builds (#13540)** — *"The unauthenticated public build path …
> builds a public flow as its owner and executes its components, running each
> node's stored `code` … The custom-component hash gate
> (validate_flow_for_current_settings) is a no-op under the default
> allow_custom_components=true, so a public flow containing a plain
> CustomComponent — or any node carrying arbitrary code — would execute on that
> path without authentication (follow-up to H1-3754930)."*

That commit is an ancestor of `v1.10.1` but **not** of `v1.10.0`/`v1.9.0`
(verified with `git merge-base --is-ancestor`). So **v1.9.0 through v1.10.0 are
all vulnerable to this bypass**; only v1.10.1 closes it.

## How the v1.10.1 follow-up closes the gap (for contrast)

`v1.10.1` adds `prepare_public_flow_build(flow.data)` /
`validate_public_flow_no_code_execution` on the public path (`chat.py:~854`):
instead of relying on the no-op hash gate, it **substitutes the server's trusted
code** for every known component type and **rejects** nodes whose type is not a
known server component carrying code (HTTP 400). A new setting
`allow_public_custom_components` (default `false`,
`src/lfx/src/lfx/services/settings/groups/security.py`) lets operators
explicitly opt back in to the old DB-loaded behavior. Empirically, the bypass
returns `exploit_status=400 {"detail":"This flow cannot be executed."}` and
writes no proof on `1.10.1` (2/2 attempts), vs `exploit_status=200` + proof
written on `1.9.0` (2/2 attempts).

## Behavior comparison

| Step | v1.8.1 (vuln) | v1.9.0 (CVE "fixed") | v1.10.1 (follow-up fixed) |
|---|---|---|---|
| Public build accepts client `data` in body | yes | **no** (removed) | no |
| Public build loads flow from DB | only if `data` omitted | **always** (`data=None`, `source_flow_id`) | always (then sanitized) |
| Stored custom-component `code` exec'd at build time | yes | **yes** | no (trusted code substituted / rejected) |
| `validate_flow_for_current_settings` gate | n/a | **no-op** (`allow_custom_components=true`) | superseded by `prepare_public_flow_build` |
| `POST /api/v1/flows/` validates custom components | no | no | (not the public-build gate) |
| Unauth RCE via stored malicious PUBLIC flow | yes (trivially, `data` also works) | **YES (bypass)** | no (400) |

## Target's stated threat model

Langflow's own security-setting documentation (main-branch
`src/lfx/src/lfx/services/settings/groups/security.py`) states the intended
model explicitly:

> *"The global allow_custom_components flag grants custom-code execution to
> **authenticated** users; it is intentionally not extended to the
> **unauthenticated** public path, which builds flows as their owner
> (report H1-3754930 follow-up)."*

That is: authenticated users running custom components is in-scope/by-design;
**unauthenticated** visitors executing arbitrary custom-component code on the
public path is **out of scope and must be prevented**. The v1.9.0 fix fails to
enforce this on the public path under default settings — which is precisely the
threat model the bypass violates. No `SECURITY.md` in the repository scopes
"public flows executing stored custom component code" as acceptable behavior;
the H1-3754930 follow-up confirms the maintainers consider it a security defect.

## Completeness verdict

The v1.9.0 fix is **incomplete**. It correctly closes the *in-request* `data`
injection vector but leaves the *stored-data* injection vector open on the same
unauthenticated public sink, because its only added validator
(`validate_flow_for_current_settings`) is a no-op under the default
`allow_custom_components=true`, and `POST /api/v1/flows/` performs no
custom-component validation when storing a public flow. The bypass reproduces
unauthenticated RCE on the CVE's "fixed" `1.9.0` and is closed only by the later
`v1.10.1` follow-up.

### Recommendation for a complete fix (what the Coding stage should ensure)

1. On the **public** build path, never exec node `code` sourced from the DB as-is;
   substitute the server's trusted code for known component types and reject
   unknown/custom types carrying code (this is exactly what `v1.10.1`'s
   `prepare_public_flow_build` does).
2. Make the public-path hardening **independent** of the global
   `allow_custom_components` flag (which controls authenticated behavior), so
   that `allow_custom_components=true` does not silently disable public-path
   protection. The `allow_public_custom_components` opt-in (default `false`) is
   the correct pattern.
3. Validate custom-component code at **flow storage time**
   (`POST /api/v1/flows/` / `PATCH`) for `access_type=PUBLIC` flows, so a
   malicious public flow can never be persisted in the first place.
4. Ensure the same hardening covers all public graph-build entry points
   (e.g. `/build_public_tmp/{flow_id}/flow`, and any future public build/run
   endpoints), not only the one referenced in the CVE.
