From 14368f087e8b97ddf9f2ebee6424f5100200eb49 Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Thu, 25 Jun 2026 12:00:32 -0300 Subject: [PATCH] fix(api): echo requested flow identifier in v2 workflow 404 reframes A denial or owner-override 404 echoed str(flow.id) (the resolved internal UUID). When the caller referenced the flow by endpoint name, that leaked the canonical UUID and changed the flow_id in the error body vs the request. The pre-seam handler echoed the requested identifier; restore that. authorize_flow_action takes the requested id (LangflowWorkflowHost passes ResolvedFlow.flow_id, which already holds the caller's value); the owner-gate reframe uses parsed.flow_id. Adds a test that a denied endpoint-name request echoes the name, not the UUID. Also documents ResolvedFlow.graph as a host-defined artifact (Graph for serve, FlowRead for langflow), per review. --- src/backend/base/langflow/api/v2/workflow.py | 20 +++++++++++----- .../base/langflow/api/v2/workflow_host.py | 2 +- .../tests/unit/api/v2/test_workflow_agui.py | 23 +++++++++++++++++++ src/lfx/src/lfx/workflow/host.py | 13 +++++++---- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/backend/base/langflow/api/v2/workflow.py b/src/backend/base/langflow/api/v2/workflow.py index 1c789a6bea..f91a01213c 100644 --- a/src/backend/base/langflow/api/v2/workflow.py +++ b/src/backend/base/langflow/api/v2/workflow.py @@ -146,13 +146,20 @@ async def resolve_flow_for_execution(flow_id: str, current_user: UserRead): ) from err -async def authorize_flow_action(current_user: UserRead, flow, action: WorkflowAction) -> None: +async def authorize_flow_action( + current_user: UserRead, flow, action: WorkflowAction, *, requested_id: str | None = None +) -> None: """Map a ``WorkflowAction`` to a ``FlowAction`` and enforce it. A denied share-aware fetch is reframed to 404 so flow existence is not - leaked through a raw 403. + leaked through a raw 403. ``requested_id`` is the identifier the caller sent + (an endpoint name or a UUID); the reframed body echoes it instead of the + resolved internal UUID. Falls back to ``flow.id`` when not provided. """ flow_action = FlowAction.READ if action == WorkflowAction.READ else FlowAction.EXECUTE + # Echo the identifier the caller requested (which may be an endpoint name), + # not the resolved internal UUID, so a denial does not leak the canonical id. + echo_id = requested_id if requested_id is not None else str(flow.id) try: await ensure_flow_permission( current_user, @@ -163,11 +170,11 @@ async def authorize_flow_action(current_user: UserRead, flow, action: WorkflowAc folder_id=getattr(flow, "folder_id", None), ) except HTTPException as exc: - privacy = _flow_not_found_privacy_exception(exc, str(flow.id)) + privacy = _flow_not_found_privacy_exception(exc, echo_id) # Preserve the legacy contract: any 404 on this path surfaces as the # structured FLOW_NOT_FOUND body, not the privacy-reframe's string detail. if privacy.status_code == status.HTTP_404_NOT_FOUND: - raise _flow_not_found_http_exception(str(flow.id)) from exc + raise _flow_not_found_http_exception(echo_id) from exc raise privacy from exc @@ -178,9 +185,10 @@ def _apply_execution_gates(parsed, flow, current_user: UserRead) -> None: _enforce_flow_data_override_owner(parsed, flow, current_user) except HTTPException as exc: # The owner-override deny is a privacy 404 (string detail); re-wrap to the - # structured FLOW_NOT_FOUND body to match the legacy single-try contract. + # structured FLOW_NOT_FOUND body using the requested identifier (parsed.flow_id + # may be an endpoint name) so the resolved UUID is not leaked. if exc.status_code == status.HTTP_404_NOT_FOUND: - raise _flow_not_found_http_exception(str(flow.id)) from exc + raise _flow_not_found_http_exception(str(parsed.flow_id)) from exc raise _validate_flow_data_for_execution(parsed, flow) diff --git a/src/backend/base/langflow/api/v2/workflow_host.py b/src/backend/base/langflow/api/v2/workflow_host.py index 1e72d2b68c..f1475a0789 100644 --- a/src/backend/base/langflow/api/v2/workflow_host.py +++ b/src/backend/base/langflow/api/v2/workflow_host.py @@ -86,7 +86,7 @@ class LangflowWorkflowHost(WorkflowHostBase): """Map the workflow action to a flow action and enforce it (deny -> 404).""" from langflow.api.v2.workflow import authorize_flow_action - await authorize_flow_action(caller, flow.graph, action) + await authorize_flow_action(caller, flow.graph, action, requested_id=flow.flow_id) async def run_sync( self, diff --git a/src/backend/tests/unit/api/v2/test_workflow_agui.py b/src/backend/tests/unit/api/v2/test_workflow_agui.py index cc8c989a6a..1ccdd274bd 100644 --- a/src/backend/tests/unit/api/v2/test_workflow_agui.py +++ b/src/backend/tests/unit/api/v2/test_workflow_agui.py @@ -337,6 +337,29 @@ class TestV2WorkflowAdmission: assert exc_info.value.status_code == 404 assert exc_info.value.detail["code"] == "FLOW_NOT_FOUND" + async def test_denial_echoes_requested_identifier_not_resolved_uuid(self, monkeypatch: pytest.MonkeyPatch): + """A denial on a flow referenced by endpoint name must not leak the resolved UUID.""" + from langflow.api.v2 import workflow as workflow_module + from lfx.workflow.actions import WorkflowAction + + resolved_uuid = uuid4() + flow = SimpleNamespace(id=resolved_uuid, user_id=uuid4(), workspace_id=None, folder_id=None) + + async def _deny(*_args, **_kwargs): + raise HTTPException(status_code=403, detail="denied") + + monkeypatch.setattr(workflow_module, "ensure_flow_permission", _deny) + + with pytest.raises(HTTPException) as exc_info: + await workflow_module.authorize_flow_action( + SimpleNamespace(id=uuid4()), flow, WorkflowAction.EXECUTE, requested_id="my-endpoint" + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == "FLOW_NOT_FOUND" + assert exc_info.value.detail["flow_id"] == "my-endpoint" + assert str(resolved_uuid) not in str(exc_info.value.detail) + def test_private_route_applies_component_policy_gate(self, monkeypatch: pytest.MonkeyPatch): """The authenticated v2 route must run server-side component policy validation.""" from langflow.api.v2 import workflow as workflow_module diff --git a/src/lfx/src/lfx/workflow/host.py b/src/lfx/src/lfx/workflow/host.py index aaf3e6421e..42bb6e6645 100644 --- a/src/lfx/src/lfx/workflow/host.py +++ b/src/lfx/src/lfx/workflow/host.py @@ -37,14 +37,19 @@ if TYPE_CHECKING: class ResolvedFlow(BaseModel): """Post-auth, post-RBAC artifact the router runs. - No tenant fields leak into the router body. ``graph`` is the run-ready - ``Graph``; the host owns any deepcopy/stamp semantics before handing it back. + No tenant fields leak into the router body. ``flow_id`` is the identifier the + caller requested (an endpoint name or a UUID), kept so error bodies can echo + it instead of the resolved internal id. ``graph`` is a host-defined execution + artifact, NOT necessarily an ``lfx`` ``Graph``: bare serve stores a run-ready + ``Graph`` (the default ``run_sync``/``stream_response`` consume it directly), + while the langflow host stores a ``FlowRead`` and overrides those methods. The + host owns any deepcopy/stamp semantics before handing it back. """ model_config = {"arbitrary_types_allowed": True} - flow_id: str - graph: Any # the run-ready Graph (deep-copied/stamped by the host) + flow_id: str # the caller-requested identifier (endpoint name or UUID) + graph: Any # host-defined execution artifact (Graph for serve, FlowRead for langflow) session_id_default: str | None = None # SSE thread_id / session fallback