Skip to content

Commit 00eb584

Browse files
fix(kb): enforce per-user component path containment (#14065)
* fix(kb): enforce per-user path containment * fix(security): protect Docling Serve outbound requests (#14033) * fix(security): protect Docling Serve requests * fix(docling): support Self on Python 3.10 * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * fix(auth): verify current password on password changes (#14034) * fix(security): harden MCP stdio configuration (#14036) * fix(security): restrict MCP stdio package sources * fix(security): block MCP Docker host access * fix(security): validate embedded MCP stdio configs * fix: require executable-only MCP commands * test: use allowed MCP commands in timeout tests * fix(security): harden component code module access (#14032) * fix(security): harden component code module access * fix(security): block native FFI imports in generated code * fix: track module assignment aliases in code scanner * fix(security): address alias review findings * fix: correct deprecated model settings behavior (#14047) * fix: prevent deprecated model enablement * test: use supported model in credential cleanup * fix(voice): enforce flow authorization on websocket (#14043) * fix(security): block native FFI imports in generated code (#14040) * fix(security): block native FFI imports in generated code * fix: track module assignment aliases in code scanner * chore: resolve code security conflicts * fix(security): confine AssemblyAI audio file access (#14037) * fix(security): confine AssemblyAI audio file access * [autofix.ci] apply automated fixes * test: stub optional AssemblyAI dependency * fix(security): harden AssemblyAI file submission --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: track assignment aliases in component code scanner (#14041) fix: bind loop and comprehension aliases --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent ebc7c2a commit 00eb584

7 files changed

Lines changed: 92 additions & 10 deletions

File tree

src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/tests/unit/components/files_and_knowledge/test_ingestion.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ def test_validate_column_config_invalid_column(self, component_class, default_kw
106106
with pytest.raises(ValueError, match="Column 'nonexistent' not found in DataFrame"):
107107
component._validate_column_config(data_df)
108108

109+
@pytest.mark.parametrize("knowledge_base", ["../../outside", "../victim/secret_kb"])
110+
async def test_kb_path_rejects_paths_outside_the_current_user_directory(
111+
self, component_class, default_kwargs, knowledge_base
112+
):
113+
default_kwargs["knowledge_base"] = knowledge_base
114+
component = component_class(**default_kwargs)
115+
116+
with pytest.raises(ValueError, match="KB path escapes root directory"):
117+
await component._kb_path()
118+
109119
def test_new_knowledge_dialog_uses_provider_credentials(self, component_class, default_kwargs):
110120
"""Test the create-knowledge dialog no longer exposes a redundant API key override."""
111121
component = component_class(**default_kwargs)

src/backend/tests/unit/components/files_and_knowledge/test_knowledge.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,48 @@ def test_retrieval_module_reexports_filter_helpers(self) -> None:
308308
assert _chunk_matches_filter(None, {}) is True
309309

310310

311+
# ---------------------------------------------------------------------------
312+
# Knowledge Base path isolation
313+
# ---------------------------------------------------------------------------
314+
class TestKnowledgeBasePathIsolation:
315+
@pytest.mark.parametrize(
316+
"component_class",
317+
[KnowledgeComponent, KnowledgeIngestionComponent, KnowledgeBaseComponent],
318+
)
319+
@pytest.mark.parametrize("knowledge_base", ["../../outside", "../victim/secret_kb"])
320+
def test_rejects_kb_names_outside_the_current_user_directory(
321+
self, component_class, knowledge_base, tmp_path
322+
) -> None:
323+
component = component_class(knowledge_base=knowledge_base)
324+
325+
with pytest.raises(ValueError, match="KB path escapes root directory"):
326+
component._resolve_kb_path(tmp_path, "attacker", knowledge_base)
327+
328+
def test_rejects_username_that_escapes_the_global_kb_root(self, tmp_path) -> None:
329+
component = KnowledgeComponent(knowledge_base="safe_kb")
330+
331+
with pytest.raises(ValueError, match="KB path escapes root directory"):
332+
component._resolve_kb_path(tmp_path, "../outside", "safe_kb")
333+
334+
def test_rejects_symlink_escape_from_current_user_directory(self, tmp_path) -> None:
335+
user_root = tmp_path / "attacker"
336+
outside_root = tmp_path.parent / "outside"
337+
user_root.mkdir()
338+
outside_root.mkdir(exist_ok=True)
339+
(user_root / "linked").symlink_to(outside_root, target_is_directory=True)
340+
component = KnowledgeComponent(knowledge_base="linked/secret_kb")
341+
342+
with pytest.raises(ValueError, match="KB path escapes root directory"):
343+
component._resolve_kb_path(tmp_path, "attacker", "linked/secret_kb")
344+
345+
def test_preserves_kb_names_inside_the_current_user_directory(self, tmp_path) -> None:
346+
component = KnowledgeComponent(knowledge_base="existing_kb")
347+
348+
assert component._resolve_kb_path(tmp_path, "current_user", "existing_kb") == (
349+
tmp_path / "current_user" / "existing_kb"
350+
)
351+
352+
311353
# ---------------------------------------------------------------------------
312354
# Display name / dropdown UX (matches starter projects + frontend create dialog)
313355
# ---------------------------------------------------------------------------

src/backend/tests/unit/components/files_and_knowledge/test_retrieval.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,21 @@ async def test_retrieve_data_missing_user_record_raises(self, component_class, d
373373
with pytest.raises(ValueError, match=r"User with ID .* not found"):
374374
await component.retrieve_data()
375375

376+
@pytest.mark.parametrize("knowledge_base", ["../../outside", "../victim/secret_kb"])
377+
async def test_retrieve_data_rejects_paths_outside_the_current_user_directory(
378+
self, component_class, default_kwargs, knowledge_base
379+
):
380+
default_kwargs["knowledge_base"] = knowledge_base
381+
component = component_class(**default_kwargs)
382+
383+
with (
384+
patch.object(component, "_get_kb_metadata") as mock_get_metadata,
385+
pytest.raises(ValueError, match="KB path escapes root directory"),
386+
):
387+
await component.retrieve_data()
388+
389+
mock_get_metadata.assert_not_called()
390+
376391
async def test_retrieve_data_routes_query_with_scores(
377392
self,
378393
component_class,

src/lfx/src/lfx/_assets/component_index.json

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

src/lfx/src/lfx/components/files_and_knowledge/knowledge.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,9 @@ async def update_build_config(
543543
msg = f"Embedding validation failed: {e!s}"
544544
raise ValueError(msg) from e
545545

546-
kb_path = _get_knowledge_bases_root_path() / kb_user / field_value["01_new_kb_name"]
546+
kb_path = self._resolve_kb_path(
547+
_get_knowledge_bases_root_path(), kb_user, field_value["01_new_kb_name"]
548+
)
547549
kb_path.mkdir(parents=True, exist_ok=True)
548550

549551
build_config["knowledge_base"]["value"] = field_value["01_new_kb_name"]
@@ -594,6 +596,19 @@ def _get_kb_root(self) -> Path:
594596
"""Return the root directory for knowledge bases."""
595597
return _get_knowledge_bases_root_path()
596598

599+
@staticmethod
600+
def _resolve_kb_path(kb_root: Path, kb_user: str, kb_name: str) -> Path:
601+
"""Resolve the selected KB inside the authenticated user's directory."""
602+
# Lazy import keeps lfx importable without langflow's DB services.
603+
from langflow.services.memory_base.kb_path_helpers import validate_kb_path
604+
605+
user_root = kb_root / kb_user
606+
validate_kb_path(kb_root, user_root)
607+
608+
kb_path = user_root / kb_name
609+
validate_kb_path(user_root, kb_path)
610+
return kb_path
611+
597612
@staticmethod
598613
def _scalar_notna(value) -> bool:
599614
"""Check if a value is not NA, safely handling arrays and sequences.
@@ -1119,7 +1134,7 @@ async def _kb_path(self) -> Path | None:
11191134

11201135
kb_root = self._get_kb_root()
11211136

1122-
self._cached_kb_path = kb_root / kb_user / self.knowledge_base
1137+
self._cached_kb_path = self._resolve_kb_path(kb_root, kb_user, self.knowledge_base)
11231138

11241139
return self._cached_kb_path
11251140

@@ -1625,7 +1640,7 @@ async def retrieve_data(self) -> DataFrame:
16251640
msg = f"User with ID {self.user_id} not found."
16261641
raise ValueError(msg)
16271642
kb_user = current_user.username
1628-
kb_path = _get_knowledge_bases_root_path() / kb_user / self.knowledge_base
1643+
kb_path = self._resolve_kb_path(_get_knowledge_bases_root_path(), kb_user, self.knowledge_base)
16291644

16301645
metadata = self._get_kb_metadata(kb_path)
16311646
if not metadata:

0 commit comments

Comments
 (0)