108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
"""Tests for the /api/files sandbox — _resolve_sandboxed must block path traversal."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
DASH = PROJECT_ROOT / "dashboard"
|
|
|
|
if str(DASH) not in sys.path:
|
|
sys.path.insert(0, str(DASH))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def files_module():
|
|
from handlers import files as _f # type: ignore
|
|
return _f
|
|
|
|
|
|
@pytest.fixture
|
|
def handler(files_module):
|
|
class _Stub(files_module.FilesHandlers):
|
|
def __init__(self):
|
|
self.captured = None
|
|
self.captured_code = None
|
|
|
|
def send_json(self, data, code=200):
|
|
self.captured = data
|
|
self.captured_code = code
|
|
|
|
return _Stub()
|
|
|
|
|
|
@pytest.fixture
|
|
def sandboxed(tmp_path, monkeypatch):
|
|
"""Replace ALLOWED_WORKSPACES with a single tmp_path to isolate tests."""
|
|
import constants # type: ignore
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
monkeypatch.setattr(constants, "ALLOWED_WORKSPACES", [root])
|
|
return root
|
|
|
|
|
|
def test_resolve_accepts_file_inside_workspace(handler, sandboxed):
|
|
(sandboxed / "notes.md").write_text("hello", encoding="utf-8")
|
|
target, workspace = handler._resolve_sandboxed("notes.md")
|
|
assert target is not None
|
|
assert target == (sandboxed / "notes.md").resolve()
|
|
assert workspace == sandboxed
|
|
|
|
|
|
def test_resolve_accepts_nested_file(handler, sandboxed):
|
|
(sandboxed / "sub").mkdir()
|
|
(sandboxed / "sub" / "file.txt").write_text("x", encoding="utf-8")
|
|
target, workspace = handler._resolve_sandboxed("sub/file.txt")
|
|
assert target == (sandboxed / "sub" / "file.txt").resolve()
|
|
assert workspace == sandboxed
|
|
|
|
|
|
def test_resolve_rejects_parent_traversal(handler, sandboxed):
|
|
"""../etc/passwd-style requests must resolve OUTSIDE the workspace and
|
|
be refused by returning (None, None)."""
|
|
target, workspace = handler._resolve_sandboxed("../../../etc/passwd")
|
|
assert target is None
|
|
assert workspace is None
|
|
|
|
|
|
def test_resolve_rejects_absolute_escape(handler, sandboxed):
|
|
"""Absolute path that lands outside the workspace must be refused.
|
|
|
|
Note: Path('/base') / '/etc/passwd' == Path('/etc/passwd') because the
|
|
second path is absolute. The resolver must still refuse it.
|
|
"""
|
|
target, workspace = handler._resolve_sandboxed("/etc/passwd")
|
|
assert target is None, "absolute outside path must be refused"
|
|
assert workspace is None
|
|
|
|
|
|
def test_handle_files_get_returns_403_on_denied(handler, sandboxed):
|
|
"""End-to-end: /api/files with a traversal path must 403."""
|
|
# Simulate HTTP path parsing: the endpoint reads self.path
|
|
handler.path = "/api/files?path=../../../etc/passwd&action=list"
|
|
handler.handle_files_get()
|
|
assert handler.captured_code == 403
|
|
assert handler.captured["error"] == "Access denied"
|
|
|
|
|
|
def test_handle_files_get_reads_a_file(handler, sandboxed):
|
|
(sandboxed / "hello.txt").write_text("hi there", encoding="utf-8")
|
|
handler.path = "/api/files?path=hello.txt&action=list"
|
|
handler.handle_files_get()
|
|
assert handler.captured_code == 200 or handler.captured_code is None
|
|
assert handler.captured["type"] == "file"
|
|
assert handler.captured["content"] == "hi there"
|
|
|
|
|
|
def test_handle_files_get_lists_a_dir(handler, sandboxed):
|
|
(sandboxed / "a.md").write_text("a", encoding="utf-8")
|
|
(sandboxed / "b.md").write_text("b", encoding="utf-8")
|
|
(sandboxed / "nested").mkdir()
|
|
handler.path = "/api/files?path=&action=list"
|
|
handler.handle_files_get()
|
|
assert handler.captured["type"] == "dir"
|
|
names = {item["name"] for item in handler.captured["items"]}
|
|
assert names == {"a.md", "b.md", "nested"}
|