Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/backend/base/langflow/api/v1/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from lfx.services.cache.utils import CacheMiss
from lfx.utils.flow_validation import (
CustomComponentValidationError,
prepare_public_flow_build,
validate_flow_for_current_settings,
validate_public_flow_no_code_execution,
)
Expand Down Expand Up @@ -832,18 +833,25 @@ async def build_public_tmp(
if scoped_session != inputs.session:
inputs = inputs.model_copy(update={"session": scoped_session})

# Validate the stored flow data after the public-access boundary.
# Public flows never accept client-supplied data.
# Validate the stored flow data after the public-access boundary. Public flows never
# accept client-supplied data; the two checks below harden the unauthenticated build
# path (report H1-3754930) and only ever run server-trusted code for anonymous visitors.
sanitized_public_data: dict | None = None
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)
# Block unauthenticated builds of flows that run arbitrary code
# (Python interpreter/REPL, legacy Python Code Structured tool,
# Smart Transform lambda). Without this, any public flow
# containing such a component is an unauthenticated server-side
# code-execution primitive (report H1-3754930).
# Smart Transform lambda) or invoke another saved flow (Run Flow,
# Sub Flow, Flow as Tool — the transitive case). Without this, any
# public flow containing such a component is an unauthenticated
# server-side code-execution primitive (report H1-3754930).
validate_public_flow_no_code_execution(flow.data)
# Substitute the server's trusted code into every known component and
# reject unrecognized custom components, so anonymous visitors only ever
# run server code (opt out with allow_public_custom_components, which
# restores the prior DB-loaded build that honors allow_custom_components).
sanitized_public_data = await prepare_public_flow_build(flow.data)

# flow_id=new_flow_id for tracking/sessions/messages (virtual, per-user isolation).
# source_flow_id=flow_id to load the actual flow data from the database.
Expand All @@ -852,7 +860,19 @@ async def build_public_tmp(
source_flow_id=flow_id,
background_tasks=background_tasks,
inputs=inputs,
data=None, # Always None - public flows load from database only
# Default path: build from server-sanitized data (trusted code substituted in,
# unknown custom components already rejected above). When None (opt-in mode or no
# flow data) the build falls back to loading the flow from the DB by source_flow_id.
# Either way the caller never supplies the data — it is derived from the stored flow.
data=(
FlowDataRequest(
nodes=sanitized_public_data.get("nodes", []),
edges=sanitized_public_data.get("edges", []),
viewport=sanitized_public_data.get("viewport"),
)
if sanitized_public_data is not None
else None
),
files=files,
stop_component_id=stop_component_id,
start_component_id=start_component_id,
Expand Down
46 changes: 46 additions & 0 deletions src/backend/tests/unit/test_chat_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,52 @@ async def test_build_public_tmp_without_data_parameter(client, json_memory_chatb
assert "job_id" in response_data


@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_rejects_custom_component(client, json_memory_chatbot_no_llm, logged_in_headers):
"""H1-3754930 follow-up: an unrecognized custom component must be rejected on the public path.

Under the default allow_public_custom_components=False, the unauthenticated public build path
runs only the server's trusted code for known component types and rejects custom/unknown
component code, so an anonymous visitor cannot trigger arbitrary server-side code execution
even when allow_custom_components is True (the default).
"""
flow_dict = json.loads(json_memory_chatbot_no_llm)
flow_dict["data"]["nodes"].append(
{
"id": "EvilCustom-1",
"type": "genericNode",
"position": {"x": 0, "y": 0},
"data": {
"id": "EvilCustom-1",
"type": "TotallyCustomEvilComponent",
"node": {
"display_name": "Evil Custom",
"template": {"code": {"value": "import os\nos.system('id')\n"}},
},
},
}
)
flow_id = await create_flow(client, json.dumps(flow_dict), logged_in_headers)

response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert response.status_code == codes.OK

client.cookies.set("client_id", "test-custom-component-client")
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": "test_session"}},
headers={"Content-Type": "application/json"},
)

assert response.status_code == codes.BAD_REQUEST
assert response.json()["detail"] == "This flow cannot be executed."


@pytest.mark.benchmark
@pytest.mark.security
async def test_get_build_events_public_tmp_job_accessible_by_any_auth_user(
Expand Down
13 changes: 13 additions & 0 deletions src/lfx/src/lfx/services/settings/groups/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ class SecuritySettings(BaseModel):
Has no effect when allow_custom_components is True (the flag is not blocking anything
to override)."""

allow_public_custom_components: bool = False
"""If set to True, the unauthenticated public flow build path
(POST /api/v1/build_public_tmp/{flow_id}/flow) honors allow_custom_components just like
the authenticated build path, building the flow from the database as its owner.

Default is False: on the public path the server substitutes its own trusted code into
every known component and rejects unrecognized custom components, so anonymous visitors
can only ever run server code that matches a known component template. 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). Enable this only if you knowingly want public flows to
run custom component code permitted by allow_custom_components."""

# Rate Limiting
rate_limit_enabled: bool = True
"""Enable rate limiting for login endpoint. Set to False to disable (useful for testing)."""
Expand Down
173 changes: 173 additions & 0 deletions src/lfx/src/lfx/utils/flow_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,179 @@ def validate_flow_for_current_settings(target: Mapping[str, Any] | Any | None) -
)


def collect_component_code_lookups(all_types_dict: Mapping[str, Any]) -> dict[str, str]:
"""Map each known component type (and its aliases) to the server's trusted code.

The value is the component's source as served by this instance
(``template.code.value``). Used to substitute trusted code into public-flow nodes so the
build runs server code, not the node's stored bytes. First alias wins on collision.
"""
type_to_code: dict[str, str] = {}

for category_components in all_types_dict.values():
if not isinstance(category_components, Mapping):
continue

for component_name, component_data in category_components.items():
if not isinstance(component_data, Mapping):
continue

template = component_data.get("template")
if not isinstance(template, Mapping):
continue

code_field = template.get("code")
code = code_field.get("value") if isinstance(code_field, Mapping) else None
if not isinstance(code, str) or not code:
continue

for alias in get_component_type_aliases(component_name, component_data):
type_to_code.setdefault(alias, code)

return type_to_code


def _substitute_trusted_node_code(nodes: list, type_to_code: dict[str, str]) -> list[str]:
"""Replace each code-bearing node's code with the server's trusted copy for its type.

Mutates the given node dicts in place (callers pass a copy). A node carries an execution
vector only through a non-empty ``template.code.value`` — for those nodes:
* known component type → its stored code is overwritten with the server's trusted code, so
version drift cannot break the build and a relabelled node cannot smuggle in its own bytes;
* unknown component type → recorded as blocked (no trusted code exists to run).

Nodes without executable code (group/note/container nodes) are left untouched. Recurses into
inlined sub-flow definitions. Returns ``display_name (id)`` labels for blocked nodes.
"""
blocked: list[str] = []
for node in nodes:
if not isinstance(node, dict):
continue
node_data = node.get("data")
if not isinstance(node_data, dict):
continue

node_info = node_data.get("node")
node_info = node_info if isinstance(node_info, dict) else None

code_field = None
if node_info is not None:
template = node_info.get("template")
if isinstance(template, dict) and isinstance(template.get("code"), dict):
code_field = template["code"]

component_type = node_data.get("type")
if code_field is not None and code_field.get("value"):
if isinstance(component_type, str) and component_type in type_to_code:
code_field["value"] = type_to_code[component_type]
else:
Comment thread
erichare marked this conversation as resolved.
display_name = (node_info.get("display_name") if node_info else None) or component_type
node_id = node_data.get("id") or node.get("id", "unknown")
blocked.append(f"{display_name} ({node_id})")

# Recurse into inlined sub-flows (group / sub-flow nodes).
if node_info is not None:
nested_flow = node_info.get("flow")
if isinstance(nested_flow, dict):
nested_data = nested_flow.get("data")
nested_nodes = nested_data.get("nodes") if isinstance(nested_data, dict) else None
if isinstance(nested_nodes, list) and nested_nodes:
blocked.extend(_substitute_trusted_node_code(nested_nodes, type_to_code))

return blocked


async def _ensure_component_code_lookups(settings_service: Any) -> dict[str, str]:
"""Load the component registry if needed and return the type→trusted-code map (fail closed)."""
from lfx.interface.components import component_cache, get_and_cache_all_types_dict

if component_cache.all_types_dict is None:
try:
await get_and_cache_all_types_dict(settings_service)
except Exception as exc:
logger.warning("Failed to load component templates for public flow sanitization", exc_info=exc)
raise

all_types_dict = component_cache.all_types_dict
if not all_types_dict:
return {}
return collect_component_code_lookups(all_types_dict)


async def prepare_public_flow_build(target: Mapping[str, Any] | Any | None) -> dict | None:
"""Return server-trusted, build-ready flow data for the unauthenticated public build path.

``POST /api/v1/build_public_tmp/{flow_id}/flow`` builds a public flow **as its owner**
without authentication, executing the flow's components — each node's stored ``code`` is run
via ``eval_custom_component_code``. The global ``allow_custom_components`` flag is an
operator's decision to let *authenticated* users run custom (non-template) code; it must not
silently extend that trust to anonymous visitors.

Default (``allow_public_custom_components`` is False): every code-bearing node's code is
replaced with the server's trusted code for its component type, and nodes whose type is not a
known server component are rejected. Running the server's code (rather than gating on a code
hash) means legitimate flows whose stored built-in code has merely drifted across versions
still build, while arbitrary / relabelled custom code never executes. Returns the sanitized
graph dict for the caller to build from.

Opt-in (``allow_public_custom_components`` is True): preserves the prior behavior — runs the
standard custom-component validation and returns ``None`` so the caller builds from the
database as before.

Returns:
The sanitized graph dict to build from, or ``None`` to fall back to the default
database-loaded build (opt-in mode, or no flow data to sanitize).

Raises:
CustomComponentValidationError: if the flow contains an unrecognized custom component, or
the component templates cannot be loaded (fail closed).
"""
import copy

from lfx.services.deps import get_settings_service

settings_service = get_settings_service()
if settings_service is None:
raise RuntimeError(SETTINGS_SERVICE_REQUIRED_MESSAGE)

# Opt-in: honor the global custom-component policy and build from the database as before.
if settings_service.settings.allow_public_custom_components:
validate_flow_for_current_settings(target)
return None

normalized_flow_data = _extract_flow_data(target)
if normalized_flow_data is None:
# A target we cannot verify must fail closed rather than skip sanitization.
if target is not None:
msg = (
"Flow validation failed: could not extract graph data from the provided target. "
"Ensure the flow payload or Graph object contains valid graph data."
)
raise CustomComponentValidationError(msg)
return None

nodes = normalized_flow_data.get("nodes")
if not isinstance(nodes, list) or not nodes:
return None

type_to_code = await _ensure_component_code_lookups(settings_service)
if not type_to_code:
# Templates unavailable — do not let unverified code through.
raise CustomComponentValidationError(INITIALIZING_COMPONENT_TEMPLATES_MESSAGE)

sanitized = copy.deepcopy(normalized_flow_data)
blocked = _substitute_trusted_node_code(sanitized.get("nodes", []), type_to_code)
if blocked:
blocked_names = ", ".join(blocked)
logger.warning(f"Public flow build blocked: unrecognized custom components are not allowed: {blocked_names}")
message = (
f"Public flows cannot be built without authentication when they contain custom components: {blocked_names}"
)
raise CustomComponentValidationError(message)

return sanitized


def _node_code_hash(node_info: Any) -> str | None:
"""Return the code-hash of a node's ``code`` field, mirroring the hash gate.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@
"telemetry_writer_size_strategy",
"telemetry_writer_batch_size_bytes",
"telemetry_writer_max_queue_bytes",
# ---- Added in 1.10.1 ----
# SecuritySettings
"allow_public_custom_components",
}


Expand Down
Loading
Loading