Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/backend/base/langflow/agentic/services/flow_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from lfx.mcp.flow_builder_tools import set_tool_start_listener
from lfx.observability import execution_protocol
from lfx.schema.schema import InputValueRequest
from lfx.utils.file_path_security import PACKAGED_FIRST_PARTY_GRAPH_ATTR
from lfx.utils.flow_validation import CustomComponentValidationError

from langflow.agentic.services.flow_run import extract_graph_token_usage
Expand Down Expand Up @@ -130,6 +131,11 @@ async def execute_flow_file(
api_key_var,
provider_vars=global_variables,
)
# resolve_flow_path confined flow_path to the packaged flows directory, so this graph
# was built from first-party product code. Marking the graph OBJECT rather than setting
# ambient state is what keeps the file-read exemption from reaching tenant flows
# dispatched during this run: those are different Graph objects and cannot inherit it.
setattr(graph, PACKAGED_FIRST_PARTY_GRAPH_ATTR, True)

if user_id:
graph.user_id = user_id
Expand Down Expand Up @@ -223,6 +229,11 @@ async def execute_flow_file_streaming(
api_key_var,
provider_vars=global_variables,
)
# resolve_flow_path confined flow_path to the packaged flows directory, so this graph
# was built from first-party product code. Marking the graph OBJECT rather than setting
# ambient state is what keeps the file-read exemption from reaching tenant flows
# dispatched during this run: those are different Graph objects and cannot inherit it.
setattr(graph, PACKAGED_FIRST_PARTY_GRAPH_ATTR, True)
except CustomComponentValidationError as e:
logger.error(f"Flow preparation error: {e}")
raise HTTPException(status_code=400, detail=str(e)) from e
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""The shipped Langflow Assistant flow must run under the hardened enterprise settings.

``LangflowAssistant.json`` is first-party content, but it was loaded through the same
gates as tenant-supplied flows and did not satisfy them:

* ``LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false`` blocked the flow's own inline
``DataFrameKeywordSearch`` node, which has no registered server counterpart.
* ``LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true`` blocked the flow's own ``Directory``
node, rewritten at load time to the installed lfx components directory.

Both settings are baked into the enterprise image, so the assistant returned the same
error to every message, including "hi", for every user.

Both exemptions are keyed to identity, never to a time window or a caller:
``PACKAGED_FLOW_TRUSTED_CODE`` names one exact (type, source-hash) pair, and the file
read requires the component's own ``Graph`` object to carry the packaged marker. The
containment tests below are the point of this file -- a bypass is the failure mode, and
no positive test would notice one.
"""

import json
import uuid
from pathlib import Path
from types import SimpleNamespace

import pytest
from langflow.agentic.services.flow_preparation import load_and_prepare_flow
from lfx.interface.components import get_and_cache_all_types_dict
from lfx.services.deps import get_settings_service
from lfx.utils.file_path_security import (
PACKAGED_FIRST_PARTY_GRAPH_ATTR,
LocalFileAccessError,
component_may_read_package_resources,
enforce_local_file_access,
)
from lfx.utils.flow_validation import (
CODE_EXECUTION_COMPONENT_TYPES,
PACKAGED_FLOW_TRUSTED_CODE,
CustomComponentValidationError,
_compute_code_hash,
validate_flow_for_current_settings,
)

import lfx

FLOWS_DIR = Path(__file__).parents[4] / "base" / "langflow" / "agentic" / "flows"
FLOW_PATH = FLOWS_DIR / "LangflowAssistant.json"
LFX_COMPONENTS_DIR = str(Path(lfx.__file__).parent / "components")
SCOPE = str(uuid.uuid4())

TENANT_CODE = "class TenantWritten:\n pass\n"


def _tenant_flow(component_type: str = "TenantWritten", code: str = TENANT_CODE) -> dict:
return {
"nodes": [
{
"id": "Custom-abc12",
"data": {
"id": "Custom-abc12",
"type": component_type,
"node": {"display_name": "Tenant", "template": {"code": {"value": code}}},
},
}
],
"edges": [],
}


def _prepared_flow() -> dict:
return json.loads(load_and_prepare_flow(FLOW_PATH, None, None, None)).get("data", {})


def _component(*, packaged: bool):
graph = SimpleNamespace(flow_id=None, source_flow_id=None)
if packaged:
setattr(graph, PACKAGED_FIRST_PARTY_GRAPH_ATTR, True)
return SimpleNamespace(_vertex=SimpleNamespace(graph=graph))


@pytest.fixture
def hardened_settings(tmp_path):
"""The three settings the enterprise image bakes in."""
settings = get_settings_service().settings
saved = (
settings.allow_custom_components,
settings.block_code_interpreter_components,
settings.restrict_local_file_access,
settings.config_dir,
)
settings.allow_custom_components = False
settings.block_code_interpreter_components = True
settings.restrict_local_file_access = True
settings.config_dir = str(tmp_path)
(tmp_path / SCOPE).mkdir(parents=True, exist_ok=True)
try:
yield tmp_path
finally:
(
settings.allow_custom_components,
settings.block_code_interpreter_components,
settings.restrict_local_file_access,
settings.config_dir,
) = saved


@pytest.mark.usefixtures("hardened_settings")
class TestShippedAssistantFlowRunsHardened:
async def test_should_build_the_shipped_flow(self):
"""No scope, no marker, no caller privilege -- the allowlisted source is enough."""
await get_and_cache_all_types_dict(get_settings_service())
validate_flow_for_current_settings(_prepared_flow())

def test_should_read_its_own_component_library(self):
enforce_local_file_access(
LFX_COMPONENTS_DIR,
scope_ids=(SCOPE,),
allow_package_read=component_may_read_package_resources(_component(packaged=True)),
)


class TestAllowlistTracksTheShippedSource:
"""Fails loudly if the shipped flow is edited without updating the allowlist."""

def test_packaged_flow_inline_components_are_allowlisted(self):
unlisted = []
for flow_file in sorted(FLOWS_DIR.glob("*.json")):
data = json.loads(flow_file.read_text(encoding="utf-8"))
for node in data.get("data", data).get("nodes", []):
node_data = node.get("data", {})
code = (node_data.get("node", {}).get("template", {}).get("code") or {}).get("value")
if not code:
continue
entry = (node_data.get("type"), _compute_code_hash(code))
# A registered server type needs no entry; only unregistered inline code does.
if entry[0] == "DataFrameKeywordSearch" and entry not in PACKAGED_FLOW_TRUSTED_CODE:
unlisted.append(f"{flow_file.name}: {entry}")
assert not unlisted, "shipped inline component changed; update PACKAGED_FLOW_TRUSTED_CODE:\n" + "\n".join(
unlisted
)


@pytest.mark.usefixtures("hardened_settings")
class TestComponentExemptionIsKeyedToIdentity:
async def test_should_block_tenant_code(self):
await get_and_cache_all_types_dict(get_settings_service())
with pytest.raises(CustomComponentValidationError):
validate_flow_for_current_settings(_tenant_flow())

async def test_should_block_the_allowlisted_type_carrying_different_code(self):
"""The type name alone grants nothing -- the source must match."""
await get_and_cache_all_types_dict(get_settings_service())
with pytest.raises(CustomComponentValidationError):
validate_flow_for_current_settings(_tenant_flow(component_type="DataFrameKeywordSearch"))

async def test_should_block_allowlisted_code_under_a_different_type(self):
"""And the source alone grants nothing -- the pair must match."""
await get_and_cache_all_types_dict(get_settings_service())
shipped_code = next(
n["data"]["node"]["template"]["code"]["value"]
for n in _prepared_flow()["nodes"]
if n["data"].get("type") == "DataFrameKeywordSearch"
)
with pytest.raises(CustomComponentValidationError):
validate_flow_for_current_settings(_tenant_flow(component_type="SomethingElse", code=shipped_code))

async def test_should_still_block_code_interpreters(self):
"""Catalog policy and the code-interpreter block are not part of the exemption."""
await get_and_cache_all_types_dict(get_settings_service())
interpreter_flow = _tenant_flow(component_type="PythonCodeStructuredTool")
with pytest.raises(Exception, match="code-execution"):
validate_flow_for_current_settings(interpreter_flow)

def test_shipped_flow_carries_no_code_interpreter(self):
types = {n.get("data", {}).get("type") for n in _prepared_flow().get("nodes", [])}
assert not (types & CODE_EXECUTION_COMPONENT_TYPES)


@pytest.mark.usefixtures("hardened_settings")
class TestFileExemptionIsKeyedToTheGraphObject:
def test_a_tenant_graph_cannot_read_the_package(self):
"""The marker lives on one Graph object; another graph is simply a different object."""
assert component_may_read_package_resources(_component(packaged=False)) is False
with pytest.raises(LocalFileAccessError):
enforce_local_file_access(
LFX_COMPONENTS_DIR,
scope_ids=(SCOPE,),
allow_package_read=component_may_read_package_resources(_component(packaged=False)),
)

def test_a_component_with_no_graph_cannot_read_the_package(self):
assert component_may_read_package_resources(SimpleNamespace()) is False

def test_write_is_refused_even_for_the_packaged_graph(self):
with pytest.raises(LocalFileAccessError):
enforce_local_file_access(LFX_COMPONENTS_DIR, scope_ids=(SCOPE,), allow_package_read=True, for_write=True)

@pytest.mark.parametrize("forbidden", ["/etc/passwd", "/usr/bin", str(Path.home())])
def test_arbitrary_server_paths_stay_blocked(self, forbidden):
with pytest.raises(LocalFileAccessError):
enforce_local_file_access(forbidden, scope_ids=(SCOPE,), allow_package_read=True)

def test_reserved_secret_files_stay_blocked(self, hardened_settings):
secret = hardened_settings / "secret_key"
secret.write_text("x", encoding="utf-8")
with pytest.raises(LocalFileAccessError):
enforce_local_file_access(str(secret), scope_ids=(SCOPE,), allow_package_read=True)
2 changes: 1 addition & 1 deletion src/bundles/lfx-bundles/src/lfx_bundles/chroma/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def build_vector_store(self) -> Chroma:
# path to the storage dir when LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS is on so a tenant
# cannot point Chroma's on-disk sqlite store at an arbitrary host path (no-op by default).
persist_directory = (
str(enforce_local_file_access(self.resolve_path(self.persist_directory)))
str(enforce_local_file_access(self.resolve_path(self.persist_directory), for_write=True))
if self.persist_directory is not None
else None
)
Expand Down
4 changes: 3 additions & 1 deletion src/bundles/lfx-bundles/src/lfx_bundles/chroma/local_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ def build_vector_store(self) -> Chroma:
# Confine the on-disk store to the storage dir when LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS
# is on so a tenant cannot write Chroma's sqlite store to an arbitrary host path
# (no-op by default).
safe_dir = enforce_local_file_access(Path(base_dir) / "vector_stores" / self.collection_name)
safe_dir = enforce_local_file_access(
Path(base_dir) / "vector_stores" / self.collection_name, for_write=True
)
safe_dir.mkdir(parents=True, exist_ok=True)
persist_directory = str(safe_dir)
logger.debug(f"Using custom persist directory: {persist_directory}")
Expand Down
4 changes: 3 additions & 1 deletion src/bundles/lfx-bundles/src/lfx_bundles/faiss/faiss.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ def get_persist_directory(self) -> Path:
directory is confined to the storage dir before the per-user FAISS scope is appended.
"""
path = (
enforce_local_file_access(self.resolve_path(self.persist_directory)) if self.persist_directory else Path()
enforce_local_file_access(self.resolve_path(self.persist_directory), for_write=True)
if self.persist_directory
else Path()
)
if user_scope := self._user_scope(self.user_id):
return path / ".langflow_faiss" / "users" / user_scope
Expand Down
87 changes: 67 additions & 20 deletions src/lfx/src/lfx/_assets/component_index.json

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions src/lfx/src/lfx/components/files_and_knowledge/directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from lfx.schema.dataframe import DataFrame
from lfx.services.deps import get_settings_service
from lfx.template.field.base import Output
from lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access
from lfx.utils.file_path_security import (
component_file_access_scopes,
component_may_read_package_resources,
enforce_local_file_access,
)


class DirectoryComponent(Component):
Expand Down Expand Up @@ -120,7 +124,13 @@ def load_directory(self) -> list[Data]:

# Security: confine directory reads to the storage dir in restricted (multi-tenant)
# mode so a tenant cannot recursively read arbitrary server directories.
resolved_path = str(enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self)))
resolved_path = str(
enforce_local_file_access(
resolved_path,
scope_ids=component_file_access_scopes(self),
allow_package_read=component_may_read_package_resources(self),
)
)

# If no types are specified, use all supported types
if not types:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ async def _save_to_local(self) -> Message:
scope_root = Path(scope_ids[0]) if scope_ids else Path()
file_path = Path(settings.config_dir) / scope_root / file_path
file_path = self._adjust_file_path_with_format(file_path, file_format)
file_path = enforce_local_file_access(file_path, scope_ids=scope_ids)
file_path = enforce_local_file_access(file_path, scope_ids=scope_ids, for_write=True)
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)

Expand Down
Loading