mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""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")
|