From 917e3904d9a78164244dcec67dc49eb38f09c91d Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 13 Jul 2026 11:48:20 -0700 Subject: [PATCH 1/4] fix(kb): enforce folder connector security settings --- .../base/langflow/api/v1/knowledge_bases.py | 35 ++-- .../test_connector_endpoints.py | 154 +++++++++++++++++- .../ingestion_sources/folder.py | 17 +- .../src/lfx/services/settings/groups/paths.py | 9 +- .../settings/test_kb_allowed_folder_roots.py | 25 ++- .../settings/test_settings_composition.py | 3 +- 6 files changed, 214 insertions(+), 29 deletions(-) diff --git a/src/backend/base/langflow/api/v1/knowledge_bases.py b/src/backend/base/langflow/api/v1/knowledge_bases.py index 23dd3227fd37..b3317f7c12e6 100644 --- a/src/backend/base/langflow/api/v1/knowledge_bases.py +++ b/src/backend/base/langflow/api/v1/knowledge_bases.py @@ -261,6 +261,20 @@ def _build_connector_ingest_dedupe_key( return f"kb_connector_ingest:{digest}" +def _apply_folder_source_security_settings(source_config: dict[str, Any]) -> dict[str, Any]: + """Return folder config with request-controlled security fields overwritten. + + Folder paths and traversal options are request inputs, but filesystem roots + and file-size limits are operator policy. Keep that trust boundary in one + helper so both public folder-ingestion routes enforce the same settings. + """ + settings = get_settings_service().settings + secured_config = dict(source_config) + secured_config["allowed_roots"] = list(settings.kb_allowed_folder_roots or []) + secured_config["max_file_size_bytes"] = settings.kb_folder_max_file_size_bytes + return secured_config + + def _is_memory_base_associated(metadata: dict[str, Any]) -> bool: """Return True if the KB metadata indicates an association with a Memory Base.""" source_types = metadata.get("source_types") @@ -1130,9 +1144,8 @@ class IngestFolderRequest(BaseModel): """Body payload for ``POST /{kb_name}/ingest/folder``. Path is expanded (``~`` → user home) and resolved before being - checked against the settings allow-list. ``extensions`` and - ``max_file_size_bytes`` are optional — unset means "use the - FolderSource defaults". + checked against the settings allow-list. The per-file size limit is + operator-owned and is not exposed as a request field. """ path: str = Field(..., description="Absolute or ~-expanded directory to walk.") @@ -1141,7 +1154,6 @@ class IngestFolderRequest(BaseModel): None, description="Lowercase extensions without dot. None → defaults (txt, md, pdf, docx, …).", ) - max_file_size_bytes: int | None = Field(None, description="Per-file size cap; None → 25 MB default.") source_name: str = Field("", description="Optional grouping label stamped on every chunk's 'source'.") chunk_size: int = Field( 1000, @@ -1190,9 +1202,6 @@ async def ingest_folder_to_knowledge_base( _kb_guard = await _guard_kb_action(current_user=current_user, action=KnowledgeBaseAction.INGEST, kb_name=kb_name) _assert_kb_not_memory_base(kb_name, _kb_guard.owner_user) try: - settings = get_settings_service().settings - allowed_roots = settings.kb_allowed_folder_roots or [] - # Validate user-supplied metadata before resolving the KB path so a # malformed payload responds with 422 rather than 404 if the KB name # also happens to be wrong. @@ -1240,14 +1249,12 @@ async def ingest_folder_to_knowledge_base( source_config: dict[str, Any] = { "path": payload.path, "recursive": payload.recursive, - "allowed_roots": allowed_roots, } if payload.extensions is not None: source_config["extensions"] = payload.extensions - if payload.max_file_size_bytes is not None: - source_config["max_file_size_bytes"] = payload.max_file_size_bytes if per_file_user_metadata: source_config["per_file_metadata"] = per_file_user_metadata + source_config = _apply_folder_source_security_settings(source_config) folder_source = FolderSource(user_id=current_user.id, source_config=source_config) try: @@ -1829,11 +1836,15 @@ async def ingest_via_connector( metadata=metadata, ) + source_config = dict(payload.source_config) + if payload.source_type == SourceType.FOLDER.value: + source_config = _apply_folder_source_security_settings(source_config) + try: source = create_source( payload.source_type, user_id=current_user.id, - source_config=payload.source_config, + source_config=source_config, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -1853,7 +1864,7 @@ async def ingest_via_connector( user_id=current_user.id, kb_name=kb_name, source_type=payload.source_type, - source_config=payload.source_config, + source_config=source_config, ) job_service = get_job_service() diff --git a/src/backend/tests/unit/base/knowledge_bases/test_connector_endpoints.py b/src/backend/tests/unit/base/knowledge_bases/test_connector_endpoints.py index 5cde63f1e193..9e1133d842da 100644 --- a/src/backend/tests/unit/base/knowledge_bases/test_connector_endpoints.py +++ b/src/backend/tests/unit/base/knowledge_bases/test_connector_endpoints.py @@ -142,8 +142,144 @@ async def test_rejects_unbounded_chunk_parameters(self, client: AsyncClient, log assert response.status_code == 422 assert "chunk_size" in response.text + @patch("langflow.api.v1.knowledge_bases.get_task_service") + @patch("langflow.api.v1.knowledge_bases.get_job_service") + @patch("langflow.api.v1.knowledge_bases.get_settings_service") + @patch("langflow.api.v1.knowledge_bases.KBAnalysisHelper.get_metadata") + @patch("langflow.api.v1.knowledge_bases.KBStorageHelper.get_root_path") + async def test_folder_connector_rejects_request_controlled_allowed_root( + self, + mock_root, + mock_meta, + mock_settings_service, + mock_job_service, + mock_task_service, + client: AsyncClient, + logged_in_headers, + active_user, + tmp_path, + ): + configured_root = tmp_path / "configured" + configured_root.mkdir() + untrusted_root = tmp_path / "request-controlled" + untrusted_folder = untrusted_root / "docs" + untrusted_folder.mkdir(parents=True) + + mock_settings_service.return_value = SimpleNamespace( + settings=SimpleNamespace( + kb_allowed_folder_roots=[str(configured_root)], + kb_folder_max_file_size_bytes=4096, + ) + ) + mock_root.return_value = tmp_path + kb_dir = tmp_path / active_user.username / "connector_kb_untrusted_root" + kb_dir.mkdir(parents=True) + mock_meta.return_value = { + "id": "00000000-0000-0000-0000-000000000007", + "embedding_provider": "OpenAI", + "embedding_model": "text-embedding-3-small", + } + + js = AsyncMock() + js.create_job = AsyncMock() + js.execute_with_status = AsyncMock() + mock_job_service.return_value = js + ts = AsyncMock() + ts.fire_and_forget_task = AsyncMock() + mock_task_service.return_value = ts + + response = await client.post( + "api/v1/knowledge_bases/connector_kb_untrusted_root/ingest/connector", + headers=logged_in_headers, + json={ + "source_type": "folder", + "source_config": { + "path": str(untrusted_folder), + "allowed_roots": [str(untrusted_root)], + "max_file_size_bytes": 2**63, + }, + }, + ) + + assert response.status_code == 400 + assert "outside the configured allow-list" in response.json()["detail"] + js.create_job.assert_not_awaited() + ts.fire_and_forget_task.assert_not_awaited() + + @patch("langflow.api.v1.knowledge_bases.get_task_service") + @patch("langflow.api.v1.knowledge_bases.get_job_service") + @patch("langflow.api.v1.knowledge_bases.get_settings_service") + @patch("langflow.api.v1.knowledge_bases.KBAnalysisHelper.get_metadata") + @patch("langflow.api.v1.knowledge_bases.KBStorageHelper.get_root_path") + async def test_folder_connector_uses_operator_security_settings( + self, + mock_root, + mock_meta, + mock_settings_service, + mock_job_service, + mock_task_service, + client: AsyncClient, + logged_in_headers, + active_user, + tmp_path, + ): + configured_root = tmp_path / "configured" + configured_folder = configured_root / "docs" + configured_folder.mkdir(parents=True) + request_root = tmp_path / "request-controlled" + request_root.mkdir() + + mock_settings_service.return_value = SimpleNamespace( + settings=SimpleNamespace( + kb_allowed_folder_roots=[str(configured_root)], + kb_folder_max_file_size_bytes=4096, + ) + ) + mock_root.return_value = tmp_path + kb_dir = tmp_path / active_user.username / "connector_kb_configured_root" + kb_dir.mkdir(parents=True) + mock_meta.return_value = { + "id": "00000000-0000-0000-0000-000000000008", + "embedding_provider": "OpenAI", + "embedding_model": "text-embedding-3-small", + } + + js = AsyncMock() + js.create_job = AsyncMock() + js.execute_with_status = AsyncMock() + mock_job_service.return_value = js + ts = AsyncMock() + ts.fire_and_forget_task = AsyncMock() + mock_task_service.return_value = ts + + response = await client.post( + "api/v1/knowledge_bases/connector_kb_configured_root/ingest/connector", + headers=logged_in_headers, + json={ + "source_type": "folder", + "source_config": { + "path": str(configured_folder), + "allowed_roots": [str(request_root)], + "max_file_size_bytes": 1, + }, + }, + ) + + assert response.status_code == 200 + fire_call = ts.fire_and_forget_task.await_args + assert fire_call is not None + passed_source = fire_call.kwargs["source"] + assert passed_source.source_config["allowed_roots"] == [str(configured_root)] + assert passed_source.source_config["max_file_size_bytes"] == 4096 + class TestFolderIngest: + def test_request_schema_does_not_expose_file_size_policy(self): + from langflow.api.v1.knowledge_bases import IngestFolderRequest + + properties = IngestFolderRequest.model_json_schema()["properties"] + assert "max_file_size_bytes" not in properties + @patch("langflow.api.v1.knowledge_bases.get_task_service") @patch("langflow.api.v1.knowledge_bases.get_job_service") @patch("langflow.api.v1.knowledge_bases.get_settings_service") @@ -167,7 +303,10 @@ async def test_dispatches_folder_source_with_settings_allow_list( (folder / "readme.md").write_text("hello") mock_settings_service.return_value = SimpleNamespace( - settings=SimpleNamespace(kb_allowed_folder_roots=[str(allowed_root)]) + settings=SimpleNamespace( + kb_allowed_folder_roots=[str(allowed_root)], + kb_folder_max_file_size_bytes=4096, + ) ) mock_root.return_value = tmp_path kb_dir = tmp_path / active_user.username / "folder_kb" @@ -189,7 +328,12 @@ async def test_dispatches_folder_source_with_settings_allow_list( response = await client.post( "api/v1/knowledge_bases/folder_kb/ingest/folder", headers=logged_in_headers, - json={"path": str(folder), "chunk_size": 500, "chunk_overlap": 100}, + json={ + "path": str(folder), + "max_file_size_bytes": 1, + "chunk_size": 500, + "chunk_overlap": 100, + }, ) assert response.status_code == 200 @@ -199,6 +343,7 @@ async def test_dispatches_folder_source_with_settings_allow_list( assert passed_source.source_type.value == "folder" assert passed_source.source_config["path"] == str(folder) assert passed_source.source_config["allowed_roots"] == [str(allowed_root)] + assert passed_source.source_config["max_file_size_bytes"] == 4096 @patch("langflow.api.v1.knowledge_bases.get_job_service") @patch("langflow.api.v1.knowledge_bases.get_settings_service") @@ -221,7 +366,10 @@ async def test_rejects_folder_outside_settings_allow_list_before_job( outside_folder.mkdir() mock_settings_service.return_value = SimpleNamespace( - settings=SimpleNamespace(kb_allowed_folder_roots=[str(allowed_root)]) + settings=SimpleNamespace( + kb_allowed_folder_roots=[str(allowed_root)], + kb_folder_max_file_size_bytes=4096, + ) ) mock_root.return_value = tmp_path kb_dir = tmp_path / active_user.username / "folder_kb" diff --git a/src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/folder.py b/src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/folder.py index 6c1d356eaf38..1d6ba849918a 100644 --- a/src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/folder.py +++ b/src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/folder.py @@ -54,7 +54,7 @@ # Files larger than this are skipped to prevent a single pathological # file from blocking a batch. Matches WxO's per-file ceiling for PDFs -# etc. Can be overridden in ``source_config["max_file_size_bytes"]``. +# etc. Public API routes overwrite this config value from operator settings. DEFAULT_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 @@ -67,17 +67,17 @@ class FolderSource(KBIngestionSource): "path": "/absolute/or/expanded/~ path", "recursive": True, # default: True "extensions": ["pdf", "md"], # default: DEFAULT_TEXT_EXTENSIONS - "max_file_size_bytes": 10_000_000, # default: DEFAULT_MAX_FILE_SIZE_BYTES - "allowed_roots": ["/home/alice"], # required for safety + "max_file_size_bytes": 10_000_000, # operator-owned at public API boundary + "allowed_roots": ["/home/alice"], # operator-owned at public API boundary "per_file_metadata": { "relative/path.pdf": {"category": "invoice"}, "basename.txt": {"tag": ["urgent"]}, }, } - ``allowed_roots`` comes from settings at the call site — sources - don't reach into settings themselves so tests can drive the - constraint directly. + ``allowed_roots`` and ``max_file_size_bytes`` come from settings at public + API call sites — sources don't reach into settings themselves so trusted + internal callers and tests can drive the constraints directly. ``per_file_metadata`` is matched on either the relative path under the resolved root (``item_id``) or the bare filename, with the @@ -107,10 +107,7 @@ async def validate_config(self) -> None: allowed_roots = self.source_config.get("allowed_roots") or [] if not allowed_roots: - msg = ( - "FolderSource refuses to walk without an allow-list. Configure " - "LANGFLOW_KB_ALLOWED_FOLDER_ROOTS or pass 'allowed_roots' in source_config." - ) + msg = "FolderSource refuses to walk without an allow-list. Configure LANGFLOW_KB_ALLOWED_FOLDER_ROOTS." raise ValueError(msg) resolved_roots = [Path(r).expanduser().resolve() for r in allowed_roots] diff --git a/src/lfx/src/lfx/services/settings/groups/paths.py b/src/lfx/src/lfx/services/settings/groups/paths.py index 9f76aaac3a0b..582a0c1b5a26 100644 --- a/src/lfx/src/lfx/services/settings/groups/paths.py +++ b/src/lfx/src/lfx/services/settings/groups/paths.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator class PathSettings(BaseModel): @@ -23,6 +23,13 @@ class PathSettings(BaseModel): are blocked because the path is resolved before the containment check. Leave empty in multi-tenant cloud deployments to refuse arbitrary-path access.""" + kb_folder_max_file_size_bytes: int = Field(default=25 * 1024 * 1024, gt=0) + """Maximum file size accepted by Knowledge Base folder ingestion. + + Set ``LANGFLOW_KB_FOLDER_MAX_FILE_SIZE_BYTES`` to an operator-chosen positive + byte count. Public ingestion routes inject this value into ``FolderSource``; + request bodies cannot raise or lower the server-owned limit.""" + directory_component_allowed_roots: list[str] = [] """Additional directories the legacy Directory component may read from. diff --git a/src/lfx/tests/unit/services/settings/test_kb_allowed_folder_roots.py b/src/lfx/tests/unit/services/settings/test_kb_allowed_folder_roots.py index f016454b2eaf..36bc1935d1cf 100644 --- a/src/lfx/tests/unit/services/settings/test_kb_allowed_folder_roots.py +++ b/src/lfx/tests/unit/services/settings/test_kb_allowed_folder_roots.py @@ -1,4 +1,4 @@ -"""Tests for the ``kb_allowed_folder_roots`` setting. +"""Tests for the server-owned Knowledge Base folder security settings. The folder-ingestion route (``POST /{kb_name}/ingest/folder``) gates which server-side directories it may read via ``settings.kb_allowed_folder_roots``. @@ -9,10 +9,13 @@ configured and the endpoint was unusable. These tests exercise the *real* Settings model (the endpoint unit tests inject -a mocked settings object, so they never caught the missing field). +a mocked settings object, so they never caught the missing root field). The +file-size tests likewise pin the operator-owned default and environment binding. """ +import pytest from lfx.services.settings.base import Settings +from pydantic import ValidationError def test_kb_allowed_folder_roots_field_is_declared(monkeypatch): @@ -42,3 +45,21 @@ def test_kb_allowed_folder_roots_parses_comma_separated_env(monkeypatch): monkeypatch.setenv("LANGFLOW_KB_ALLOWED_FOLDER_ROOTS", "/srv/docs,/data/shared") settings = Settings() assert settings.kb_allowed_folder_roots == ["/srv/docs", "/data/shared"] + + +def test_kb_folder_max_file_size_bytes_defaults_to_25_mib(monkeypatch): + monkeypatch.delenv("LANGFLOW_KB_FOLDER_MAX_FILE_SIZE_BYTES", raising=False) + settings = Settings() + assert settings.kb_folder_max_file_size_bytes == 25 * 1024 * 1024 + + +def test_kb_folder_max_file_size_bytes_binds_from_env(monkeypatch): + monkeypatch.setenv("LANGFLOW_KB_FOLDER_MAX_FILE_SIZE_BYTES", "1048576") + settings = Settings() + assert settings.kb_folder_max_file_size_bytes == 1_048_576 + + +def test_kb_folder_max_file_size_bytes_must_be_positive(monkeypatch): + monkeypatch.setenv("LANGFLOW_KB_FOLDER_MAX_FILE_SIZE_BYTES", "0") + with pytest.raises(ValidationError): + Settings() diff --git a/src/lfx/tests/unit/services/settings/test_settings_composition.py b/src/lfx/tests/unit/services/settings/test_settings_composition.py index ce48f7fcc462..9d9fe69a43e6 100644 --- a/src/lfx/tests/unit/services/settings/test_settings_composition.py +++ b/src/lfx/tests/unit/services/settings/test_settings_composition.py @@ -147,9 +147,10 @@ "variables_to_get_from_environment", "agentic_experience", "developer_api_enabled", - # ---- Added in 1.10.0, folded into the mixins during the release back-merge ---- + # ---- Added after the original Settings split and folded into the mixins ---- # PathSettings "kb_allowed_folder_roots", + "kb_folder_max_file_size_bytes", "directory_component_allowed_roots", # McpSettings "mcp_tool_execution_timeout", From 3cb118b62d4814b80aef91dbfaa9969aaa0b3629 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 14 Jul 2026 13:52:35 -0700 Subject: [PATCH 2/4] test(chroma): isolate remote client test from SSRF DNS --- .../vectorstores/test_chroma_vector_store_component.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/backend/tests/unit/components/vectorstores/test_chroma_vector_store_component.py b/src/backend/tests/unit/components/vectorstores/test_chroma_vector_store_component.py index 57c727e8c4a9..c7bcc7c171f7 100644 --- a/src/backend/tests/unit/components/vectorstores/test_chroma_vector_store_component.py +++ b/src/backend/tests/unit/components/vectorstores/test_chroma_vector_store_component.py @@ -28,7 +28,12 @@ def embed_query(self, text: str) -> list[float]: return self._embed(text) -def test_remote_chroma_server_uses_http_client() -> None: +def test_remote_chroma_server_uses_http_client(monkeypatch: pytest.MonkeyPatch) -> None: + # This test asserts the remote HttpClient wiring (host/port/ssl), not SSRF behavior; the + # connector SSRF guard is covered separately (test_connector_ssrf.py / test_ssrf_protection.py). + # Disable connector SSRF validation so the fake ``chroma.example.com`` host is not DNS-resolved. + monkeypatch.setenv("LANGFLOW_CONNECTOR_SSRF_VALIDATION_ENABLED", "false") + mock_client = MagicMock() mock_chroma = MagicMock() mock_chroma.get.return_value = {"ids": [], "documents": [], "metadatas": []} From 4e26604e951cbe5c5b2aba67511540f3e6bd5fdb Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 14 Jul 2026 14:21:47 -0700 Subject: [PATCH 3/4] test(graph): use UUIDs for execution path users --- .../tests/unit/graph/test_execution_path_validation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/tests/unit/graph/test_execution_path_validation.py b/src/backend/tests/unit/graph/test_execution_path_validation.py index d7cedde0ca24..6814038fead5 100644 --- a/src/backend/tests/unit/graph/test_execution_path_validation.py +++ b/src/backend/tests/unit/graph/test_execution_path_validation.py @@ -142,19 +142,19 @@ async def test_flow_execution_equivalence(flow_name: str): graph_data = flow_data.get("data", flow_data) - # Create two independent copies - use valid UUIDs for flow_id + # Create two independent copies with valid UUIDs for persisted identifiers. graph_for_async_start = Graph.from_payload( deepcopy(graph_data), flow_id=str(uuid4()), flow_name=flow_name, - user_id="test-user-async", + user_id=str(uuid4()), ) graph_for_arun = Graph.from_payload( deepcopy(graph_data), flow_id=str(uuid4()), flow_name=flow_name, - user_id="test-user-arun", + user_id=str(uuid4()), ) # Run both paths with tracing From d18c67616745387e41d12dad34b189ed32338b3b Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 14 Jul 2026 14:55:33 -0700 Subject: [PATCH 4/4] test(models): isolate base URL validation from DNS --- src/backend/tests/unit/test_openai_base_url_support.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/tests/unit/test_openai_base_url_support.py b/src/backend/tests/unit/test_openai_base_url_support.py index a14307f7c327..214b4ab1f94c 100644 --- a/src/backend/tests/unit/test_openai_base_url_support.py +++ b/src/backend/tests/unit/test_openai_base_url_support.py @@ -214,6 +214,7 @@ def test_validation_normalizes_base_url_like_runtime(self): with ( patch("langchain_openai.ChatOpenAI", chat_openai), patch("lfx.utils.util.transform_localhost_url", return_value=self.TRANSFORMED), + patch("lfx.base.models.unified_models.credentials.validate_connector_url_for_ssrf") as ssrf_validator, ): validate_model_provider_key( "OpenAI", @@ -221,6 +222,7 @@ def test_validation_normalizes_base_url_like_runtime(self): model_name="gpt-oss:20b", ) + ssrf_validator.assert_called_once_with(self.TRANSFORMED) assert chat_openai.call_args.kwargs.get("base_url") == self.TRANSFORMED def test_get_llm_normalizes_base_url(self):