"""DocsService path-containment guard (CodeQL path-traversal fix, 2026-06-29). The old ``if ".." in path`` substring guard is bypassable: ``Path(base) / "/etc/passwd"`` evaluates to ``/etc/passwd`` (pathlib resets on an absolute right operand), so an absolute ``path`` escapes ``DOCS_BASE_PATH`` and the ``unlink``/``read_text`` sink hits an arbitrary file. These tests pin a resolve-and-contain guard on ``read_doc`` / ``delete_doc`` and the pure helper behind it. ``read_doc`` / ``delete_doc`` only consult the agent catalog (no DB), so a ``MagicMock`` session is enough — no Postgres required. """ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest from roboco.services.base import ValidationError from roboco.services.docs import DocsService, _resolve_contained_path if TYPE_CHECKING: from pathlib import Path from pytest import TempPathFactory # --------------------------------------------------------------------------- # Pure helper # --------------------------------------------------------------------------- def test_resolve_rejects_absolute_path(tmp_path: Path) -> None: with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, "/etc/passwd") def test_resolve_rejects_traversal_escape(tmp_path: Path) -> None: with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, "backend/../../etc/passwd") def test_resolve_rejects_empty(tmp_path: Path) -> None: with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, "") def test_resolve_rejects_nul(tmp_path: Path) -> None: with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, "backend\x00evil.md") def test_resolve_rejects_dotdot(tmp_path: Path) -> None: # A '..' segment is still rejected (traversal). with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, "backend/../etc/passwd") def test_resolve_rejects_dot_segment(tmp_path: Path) -> None: # rel='.' resolves to the base dir itself and would hand read_doc/delete_doc # a directory (500 / failed unlink). Reject it cleanly. with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, ".") def test_resolve_rejects_inner_dot_segment(tmp_path: Path) -> None: # A '.' segment mid-path (a/./b) is also rejected. with pytest.raises(ValidationError): _resolve_contained_path(tmp_path, "backend/./endpoints.md") def test_resolve_accepts_dotdot_inside_filename(tmp_path: Path) -> None: # '..' as CHARACTERS inside a single component (not a '..' segment) is a # legit filename — the old '..' substring ban false-rejected this; the # segment check allows it (it resolves safely under base). resolved = _resolve_contained_path(tmp_path, "v1..v2.md") assert resolved == (tmp_path / "v1..v2.md").resolve() assert tmp_path.resolve() in resolved.parents def test_resolve_accepts_nested_relative(tmp_path: Path) -> None: resolved = _resolve_contained_path(tmp_path, "backend/api/endpoints.md") assert resolved == (tmp_path / "backend/api/endpoints.md").resolve() assert tmp_path.resolve() in resolved.parents # --------------------------------------------------------------------------- # Service sinks — read_doc / delete_doc must reject the absolute bypass # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_read_doc_rejects_absolute_path( tmp_path: Path, tmp_path_factory: TempPathFactory ) -> None: # A file OUTSIDE the docs base — an absolute `path` must not reach its sink. outside = tmp_path_factory.mktemp("outside") / "secret.md" outside.write_text("secret", encoding="utf-8") svc = DocsService(MagicMock()) with ( patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path), pytest.raises(ValidationError), ): await svc.read_doc(agent_id="be-doc", path=str(outside)) # Unchanged: the outside file is intact (the bypass would have read it). assert outside.read_text(encoding="utf-8") == "secret" @pytest.mark.asyncio async def test_delete_doc_rejects_absolute_path( tmp_path: Path, tmp_path_factory: TempPathFactory ) -> None: # A file OUTSIDE the docs base — an absolute `path` must not reach unlink. outside = tmp_path_factory.mktemp("outside") / "marker.md" outside.write_text("marker", encoding="utf-8") svc = DocsService(MagicMock()) with ( patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path), pytest.raises(ValidationError), ): await svc.delete_doc(agent_id="be-doc", path=str(outside)) # The bypass would have DELETED this file — it must survive. assert outside.exists() @pytest.mark.asyncio async def test_read_doc_rejects_traversal(tmp_path: Path) -> None: svc = DocsService(MagicMock()) with ( patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path), pytest.raises(ValidationError), ): await svc.read_doc(agent_id="be-doc", path="backend/../../etc/passwd") @pytest.mark.asyncio async def test_read_doc_rejects_dot_segment_cleanly(tmp_path: Path) -> None: # path='.' must raise ValidationError (clean 400), NOT reach the read sink # and 500 on a directory. Proves the guard fails graceful, not loud. svc = DocsService(MagicMock()) with ( patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path), pytest.raises(ValidationError), ): await svc.read_doc(agent_id="be-doc", path=".") @pytest.mark.asyncio async def test_read_doc_normal_relative_still_works(tmp_path: Path) -> None: # Regression guard: a legit nested doc reads fine under the new guard. doc = tmp_path / "backend" / "api" / "endpoints.md" doc.parent.mkdir(parents=True, exist_ok=True) doc.write_text("# Endpoints", encoding="utf-8") svc = DocsService(MagicMock()) with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path): content, size = await svc.read_doc( agent_id="be-doc", path="backend/api/endpoints.md" ) assert content == "# Endpoints" assert size == len("# Endpoints")