Skip to content

Commit 63d4b97

Browse files
authored
fix(security): enforce admin-only component builds (#14436)
* fix(security): enforce admin-only component builds * test(lfx): isolate component loader failure test
1 parent e471ebc commit 63d4b97

7 files changed

Lines changed: 753 additions & 30 deletions

File tree

src/backend/base/langflow/api/v1/chat.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from lfx.services.cache.utils import CacheMiss
1616
from lfx.utils.flow_validation import (
1717
CustomComponentValidationError,
18+
prepare_flow_build_for_user,
1819
prepare_public_flow_build,
1920
validate_flow_for_current_settings,
2021
validate_public_flow_no_code_execution,
@@ -177,6 +178,12 @@ async def retrieve_vertices_order(
177178
if not data:
178179
graph = await build_graph_from_db(flow_id=flow_id, session=session, chat_service=chat_service)
179180
else:
181+
sanitized_data = await prepare_flow_build_for_user(
182+
data.model_dump(),
183+
is_superuser=current_user.is_superuser,
184+
)
185+
if sanitized_data is not None:
186+
data = FlowDataRequest.model_validate(sanitized_data)
180187
graph = await build_and_cache_graph_from_data(
181188
flow_id=flow_id, graph_data=data.model_dump(), chat_service=chat_service
182189
)
@@ -214,6 +221,8 @@ async def retrieve_vertices_order(
214221
raise HTTPException(status_code=400, detail=str(exc)) from exc
215222
if isinstance(exc, CustomComponentValidationError):
216223
raise HTTPException(status_code=400, detail=str(exc)) from exc
224+
if isinstance(exc, RuntimeError):
225+
raise HTTPException(status_code=503, detail=str(exc)) from exc
217226
await logger.aexception("Error checking build status")
218227
raise HTTPException(status_code=500, detail=str(exc)) from exc
219228

@@ -308,7 +317,13 @@ async def build_flow(
308317

309318
try:
310319
if data:
311-
validate_flow_for_current_settings(data.model_dump())
320+
raw_data = data.model_dump()
321+
sanitized_data = await prepare_flow_build_for_user(
322+
raw_data,
323+
is_superuser=current_user.is_superuser,
324+
)
325+
if sanitized_data is not None:
326+
data = FlowDataRequest.model_validate(sanitized_data)
312327
elif flow and flow.data:
313328
validate_flow_for_current_settings(flow.data)
314329
except CustomComponentValidationError as exc:

src/backend/base/langflow/api/v2/workflow.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,8 @@ async def authorize_flow_action(
221221
) from err
222222

223223

224-
def _apply_execution_gates(parsed, flow, current_user: UserRead) -> None:
225-
"""The langflow request gates that run before a flow executes."""
224+
def _apply_execution_gates(parsed, flow, current_user: UserRead):
225+
"""Run request gates and return any server-sanitized execution payload."""
226226
_reject_unsupported_sync_fields(parsed)
227227
_reject_sync_only_fields(parsed)
228228
try:
@@ -234,7 +234,7 @@ def _apply_execution_gates(parsed, flow, current_user: UserRead) -> None:
234234
if exc.status_code == status.HTTP_404_NOT_FOUND:
235235
raise _flow_not_found_http_exception(str(parsed.flow_id)) from exc
236236
raise
237-
_validate_flow_data_for_execution(parsed, flow)
237+
return _validate_flow_data_for_execution(parsed, flow, current_user)
238238

239239

240240
async def run_sync_with_mapping(
@@ -246,7 +246,7 @@ async def run_sync_with_mapping(
246246
background_tasks: BackgroundTasks,
247247
) -> WorkflowExecutionResponse:
248248
"""Inline sync run with the langflow timeout/validation error mapping."""
249-
_apply_execution_gates(parsed, flow, current_user)
249+
parsed = _apply_execution_gates(parsed, flow, current_user)
250250
job_id = uuid4()
251251
try:
252252
return await execute_sync_workflow_with_timeout(
@@ -321,7 +321,7 @@ def build_stream_response(
321321
side-channel, and vertex-build persistence all survive. Validation gates run
322322
before the response is constructed so a bad request fails before streaming.
323323
"""
324-
_apply_execution_gates(parsed, flow, current_user)
324+
parsed = _apply_execution_gates(parsed, flow, current_user)
325325
adapter = get_stream_adapter(
326326
stream_protocol,
327327
StreamAdapterContext(
@@ -346,7 +346,7 @@ async def submit_background_with_mapping(
346346
stream_protocol: str,
347347
) -> WorkflowJobResponse:
348348
"""Queue a durable background run with the langflow service error mapping."""
349-
_apply_execution_gates(parsed, flow, current_user)
349+
parsed = _apply_execution_gates(parsed, flow, current_user)
350350
try:
351351
return await execute_workflow_background(
352352
parsed=parsed,

src/backend/base/langflow/api/v2/workflow_validation.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88

99
from __future__ import annotations
1010

11+
from dataclasses import replace
12+
1113
from fastapi import HTTPException, status
12-
from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings
14+
from lfx.utils.flow_validation import (
15+
CustomComponentValidationError,
16+
prepare_flow_build_for_user_from_cache,
17+
validate_flow_for_current_settings,
18+
)
1319
from lfx.workflow.converters import ParsedWorkflowRun
1420

1521
from langflow.services.authorization.fetch import deny_to_404
@@ -85,17 +91,27 @@ def _enforce_flow_data_override_owner(parsed: ParsedWorkflowRun, flow: FlowRead,
8591
)
8692

8793

88-
def _validate_flow_data_for_execution(parsed: ParsedWorkflowRun, flow: FlowRead) -> None:
89-
"""Apply the same server-side component policy gate used by v1/public runs."""
94+
def _validate_flow_data_for_execution(
95+
parsed: ParsedWorkflowRun,
96+
flow: FlowRead,
97+
current_user: UserRead,
98+
) -> ParsedWorkflowRun:
99+
"""Apply component policies and return sanitized caller-supplied graph data."""
90100
try:
91101
if parsed.data is not None:
92-
validate_flow_for_current_settings(parsed.data)
102+
sanitized_data = prepare_flow_build_for_user_from_cache(
103+
parsed.data,
104+
is_superuser=current_user.is_superuser,
105+
)
106+
if sanitized_data is not None:
107+
return replace(parsed, data=sanitized_data)
93108
elif flow.data:
94109
validate_flow_for_current_settings(flow.data)
95110
except CustomComponentValidationError as exc:
96111
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
97112
except RuntimeError as exc:
98113
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
114+
return parsed
99115

100116

101117
def _validate_output_ids(output_ids: list[str] | None, terminal_node_ids: list[str]) -> None:

src/backend/tests/unit/api/v1/test_chat_build_flow_authz.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from contextlib import asynccontextmanager
1515
from types import SimpleNamespace
1616
from typing import Any
17+
from unittest.mock import AsyncMock, MagicMock
1718
from uuid import UUID, uuid4
1819

1920
import pytest
@@ -62,7 +63,12 @@ def patch_build_flow(monkeypatch):
6263
"""Install fakes for session_scope, _read_flow, ensure_flow_permission, start_flow_build."""
6364
from langflow.api.v1 import chat as chat_module
6465

65-
state: dict[str, Any] = {"session_exec": [], "read_flow": None, "ensure_raises": None}
66+
state: dict[str, Any] = {
67+
"session_exec": [],
68+
"read_flow": None,
69+
"ensure_raises": None,
70+
"start_kwargs": None,
71+
}
6672

6773
@asynccontextmanager
6874
async def fake_session_scope():
@@ -75,7 +81,8 @@ async def fake_ensure(*_args, **_kwargs):
7581
if state["ensure_raises"] is not None:
7682
raise state["ensure_raises"]
7783

78-
async def fake_start_build(**_kwargs):
84+
async def fake_start_build(**kwargs):
85+
state["start_kwargs"] = kwargs
7986
return "fake-job-id"
8087

8188
monkeypatch.setattr(chat_module, "session_scope", fake_session_scope)
@@ -171,7 +178,10 @@ async def test_build_flow_owner_can_override_flow_data(patch_build_flow, monkeyp
171178
from langflow.api.v1 import chat as chat_module
172179
from langflow.api.v1.schemas import FlowDataRequest
173180

174-
monkeypatch.setattr(chat_module, "validate_flow_for_current_settings", lambda _data: None)
181+
async def allow_inline(_data, *, is_superuser):
182+
assert is_superuser is False
183+
184+
monkeypatch.setattr(chat_module, "prepare_flow_build_for_user", allow_inline)
175185

176186
owner = _make_user()
177187
flow = _make_flow(owner_id=owner.id, public=False)
@@ -188,6 +198,152 @@ async def test_build_flow_owner_can_override_flow_data(patch_build_flow, monkeyp
188198
assert result == {"job_id": "fake-job-id"}
189199

190200

201+
@pytest.mark.asyncio
202+
async def test_build_flow_admin_only_blocks_non_superuser_inline_custom_code(patch_build_flow, monkeypatch):
203+
"""Admin-only policy is applied before owner-supplied inline graph data reaches the build worker."""
204+
from langflow.api.v1 import chat as chat_module
205+
from langflow.api.v1.schemas import FlowDataRequest
206+
from lfx.utils.flow_validation import CustomComponentValidationError
207+
208+
owner = _make_user()
209+
flow = _make_flow(owner_id=owner.id, public=False)
210+
patch_build_flow["read_flow"] = flow
211+
override = FlowDataRequest(nodes=[{"id": "custom"}], edges=[])
212+
213+
async def reject_custom_code(_data, *, is_superuser):
214+
assert is_superuser is False
215+
message = "custom components are restricted to administrators"
216+
raise CustomComponentValidationError(message)
217+
218+
monkeypatch.setattr(chat_module, "prepare_flow_build_for_user", reject_custom_code)
219+
220+
with pytest.raises(HTTPException) as excinfo:
221+
await chat_module.build_flow(
222+
flow_id=flow.id,
223+
background_tasks=None,
224+
current_user=owner,
225+
queue_service=_make_queue_service(),
226+
data=override,
227+
)
228+
229+
assert excinfo.value.status_code == 400
230+
assert "restricted to administrators" in excinfo.value.detail
231+
assert patch_build_flow["start_kwargs"] is None
232+
233+
234+
@pytest.mark.asyncio
235+
async def test_build_flow_admin_only_passes_sanitized_template_to_worker(patch_build_flow, monkeypatch):
236+
"""A known template remains usable, but the worker receives the server-trusted payload."""
237+
from langflow.api.v1 import chat as chat_module
238+
from langflow.api.v1.schemas import FlowDataRequest
239+
240+
owner = _make_user()
241+
flow = _make_flow(owner_id=owner.id, public=False)
242+
patch_build_flow["read_flow"] = flow
243+
override = FlowDataRequest(nodes=[{"id": "known", "data": {"source": "request"}}], edges=[])
244+
sanitized = {"nodes": [{"id": "known", "data": {"source": "server"}}], "edges": [], "viewport": None}
245+
246+
async def sanitize(_data, *, is_superuser):
247+
assert is_superuser is False
248+
return sanitized
249+
250+
monkeypatch.setattr(chat_module, "prepare_flow_build_for_user", sanitize)
251+
252+
result = await chat_module.build_flow(
253+
flow_id=flow.id,
254+
background_tasks=None,
255+
current_user=owner,
256+
queue_service=_make_queue_service(),
257+
data=override,
258+
)
259+
260+
assert result == {"job_id": "fake-job-id"}
261+
assert patch_build_flow["start_kwargs"]["data"].model_dump() == sanitized
262+
assert override.nodes[0]["data"]["source"] == "request"
263+
264+
265+
@pytest.mark.asyncio
266+
async def test_build_flow_admin_only_keeps_superuser_inline_custom_code(patch_build_flow, monkeypatch):
267+
"""The operator's admin-only setting preserves the documented superuser exception."""
268+
from langflow.api.v1 import chat as chat_module
269+
from langflow.api.v1.schemas import FlowDataRequest
270+
271+
owner = _make_user(is_superuser=True)
272+
flow = _make_flow(owner_id=owner.id, public=False)
273+
patch_build_flow["read_flow"] = flow
274+
override = FlowDataRequest(nodes=[{"id": "custom"}], edges=[])
275+
seen: dict[str, Any] = {}
276+
277+
async def preserve_superuser_data(data, *, is_superuser):
278+
assert is_superuser is True
279+
seen["validated"] = data
280+
281+
monkeypatch.setattr(chat_module, "prepare_flow_build_for_user", preserve_superuser_data)
282+
283+
result = await chat_module.build_flow(
284+
flow_id=flow.id,
285+
background_tasks=None,
286+
current_user=owner,
287+
queue_service=_make_queue_service(),
288+
data=override,
289+
)
290+
291+
assert result == {"job_id": "fake-job-id"}
292+
assert seen["validated"] == override.model_dump()
293+
assert patch_build_flow["start_kwargs"]["data"] is override
294+
295+
296+
@pytest.mark.asyncio
297+
async def test_legacy_vertices_route_passes_only_sanitized_inline_data(monkeypatch):
298+
"""The still-routed legacy graph cache cannot bypass the inline-data policy."""
299+
from langflow.api.v1 import chat as chat_module
300+
from langflow.api.v1.schemas import FlowDataRequest
301+
302+
owner = _make_user()
303+
flow = _make_flow(owner_id=owner.id)
304+
original = FlowDataRequest(nodes=[{"id": "known", "data": {"source": "request"}}], edges=[])
305+
sanitized = {"nodes": [{"id": "known", "data": {"source": "server"}}], "edges": [], "viewport": None}
306+
seen: dict[str, Any] = {}
307+
308+
async def ensure_allowed(*_args, **_kwargs):
309+
return None
310+
311+
async def sanitize(_data, *, is_superuser):
312+
assert is_superuser is False
313+
return sanitized
314+
315+
graph = MagicMock()
316+
graph.prepare.return_value = graph
317+
graph.vertices = []
318+
graph.vertices_to_run = set()
319+
graph.first_layer = []
320+
graph.run_id = None
321+
graph.set_run_id.side_effect = lambda value: setattr(graph, "run_id", value)
322+
323+
async def build_from_data(*, graph_data, **_kwargs):
324+
seen["graph_data"] = graph_data
325+
return graph
326+
327+
chat_service = SimpleNamespace(set_cache=AsyncMock())
328+
monkeypatch.setattr(chat_module, "ensure_flow_permission", ensure_allowed)
329+
monkeypatch.setattr(chat_module, "prepare_flow_build_for_user", sanitize)
330+
monkeypatch.setattr(chat_module, "build_and_cache_graph_from_data", build_from_data)
331+
monkeypatch.setattr(chat_module, "get_chat_service", lambda: chat_service)
332+
monkeypatch.setattr(chat_module, "get_telemetry_service", MagicMock)
333+
monkeypatch.setattr(chat_module, "get_top_level_vertices", lambda *_args: [])
334+
335+
await chat_module.retrieve_vertices_order(
336+
flow_id=flow.id,
337+
background_tasks=SimpleNamespace(add_task=lambda *_args, **_kwargs: None),
338+
data=original,
339+
session=_FakeSession([[flow]]),
340+
current_user=owner,
341+
)
342+
343+
assert seen["graph_data"] == sanitized
344+
assert original.nodes[0]["data"]["source"] == "request"
345+
346+
191347
@pytest.mark.asyncio
192348
async def test_build_flow_plugin_deny_returns_404_not_403(patch_build_flow):
193349
"""ensure_flow_permission raising 403 must surface as 404 (UUID privacy)."""

0 commit comments

Comments
 (0)