478 lines
18 KiB
Python
478 lines
18 KiB
Python
import pytest
|
|
|
|
from api.workspace import (
|
|
authorize_escape_target,
|
|
list_authorized_escape_dir,
|
|
list_dir,
|
|
read_file_content,
|
|
resolve_authorized_escape_request,
|
|
safe_resolve_ws,
|
|
)
|
|
|
|
|
|
def test_safe_resolve_blocks_external_symlink_directory(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_text("outside", encoding="utf-8")
|
|
(workspace / "escape").symlink_to(outside)
|
|
|
|
# The read/list gate still blocks navigation through the escape symlink.
|
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
|
safe_resolve_ws(workspace, "escape")
|
|
|
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
|
list_dir(workspace, "escape")
|
|
|
|
# The escape symlink is now emitted (display-only) with target_outside_workspace=True.
|
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
|
assert "escape" in entries
|
|
assert entries["escape"]["type"] == "symlink"
|
|
assert entries["escape"]["target_outside_workspace"] is True
|
|
# #4581 hardening: display-only escape rows are uniformly is_dir=False — the
|
|
# target's real dir/file nature is target-derived metadata we don't disclose
|
|
# (the row is non-navigable regardless).
|
|
assert entries["escape"]["is_dir"] is False
|
|
assert "target" not in entries["escape"]
|
|
|
|
|
|
def test_read_file_blocks_external_symlink_file(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_text("outside", encoding="utf-8")
|
|
(workspace / "secret-link.txt").symlink_to(outside / "secret.txt")
|
|
|
|
# The read gate still blocks reading through the escape symlink.
|
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
|
read_file_content(workspace, "secret-link.txt")
|
|
|
|
# The escape symlink is now emitted (display-only) with target_outside_workspace=True.
|
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
|
assert "secret-link.txt" in entries
|
|
assert entries["secret-link.txt"]["type"] == "symlink"
|
|
assert entries["secret-link.txt"]["target_outside_workspace"] is True
|
|
assert entries["secret-link.txt"]["is_dir"] is False
|
|
|
|
|
|
def test_internal_symlink_still_resolves_within_workspace(tmp_path):
|
|
import api.workspace as w
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
nested = workspace / "nested"
|
|
nested.mkdir()
|
|
(nested / "inside.txt").write_text("inside", encoding="utf-8")
|
|
(workspace / "inside-link.txt").symlink_to(nested / "inside.txt")
|
|
|
|
resolved = safe_resolve_ws(workspace, "inside-link.txt")
|
|
|
|
assert resolved == (nested / "inside.txt").resolve()
|
|
assert read_file_content(workspace, "inside-link.txt")["content"] == "inside"
|
|
if not w._DIR_FD_OK:
|
|
pytest.skip("internal symlink listing is platform-dependent without dir_fd")
|
|
assert "inside-link.txt" in {entry["name"] for entry in list_dir(workspace, ".")}
|
|
|
|
|
|
def test_authorized_escape_request_reanchors_descendants(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
(outside / "nested").mkdir(parents=True)
|
|
(outside / "nested" / "inside.txt").write_text("inside", encoding="utf-8")
|
|
(workspace / "escape").symlink_to(outside)
|
|
|
|
grant = authorize_escape_target(workspace, "sess-1", "escape")
|
|
resolved = resolve_authorized_escape_request(
|
|
workspace,
|
|
"sess-1",
|
|
grant["token"],
|
|
"escape/nested/inside.txt",
|
|
)
|
|
|
|
assert resolved["surface_path"] == "escape"
|
|
assert resolved["external_rel"] == "nested/inside.txt"
|
|
assert resolved["target"] == outside / "nested" / "inside.txt"
|
|
|
|
|
|
def test_authorized_escape_request_expires_when_surface_target_changes(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
outside_a = tmp_path / "outside-a"
|
|
outside_b = tmp_path / "outside-b"
|
|
workspace.mkdir()
|
|
outside_a.mkdir()
|
|
outside_b.mkdir()
|
|
escape = workspace / "escape"
|
|
escape.symlink_to(outside_a)
|
|
|
|
grant = authorize_escape_target(workspace, "sess-1", "escape")
|
|
|
|
escape.unlink()
|
|
escape.symlink_to(outside_b)
|
|
|
|
with pytest.raises(ValueError, match="expired"):
|
|
resolve_authorized_escape_request(workspace, "sess-1", grant["token"], "escape")
|
|
|
|
|
|
def test_authorized_escape_request_keeps_other_live_grants(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
outside_a = tmp_path / "outside-a"
|
|
outside_b = tmp_path / "outside-b"
|
|
workspace.mkdir()
|
|
outside_a.mkdir()
|
|
outside_b.mkdir()
|
|
(outside_a / "alpha.txt").write_text("alpha", encoding="utf-8")
|
|
(outside_b / "beta.txt").write_text("beta", encoding="utf-8")
|
|
(workspace / "escape-a").symlink_to(outside_a)
|
|
(workspace / "escape-b").symlink_to(outside_b)
|
|
|
|
grant_a = authorize_escape_target(workspace, "sess-1", "escape-a")
|
|
grant_b = authorize_escape_target(workspace, "sess-1", "escape-b")
|
|
|
|
resolved_a = resolve_authorized_escape_request(
|
|
workspace,
|
|
"sess-1",
|
|
grant_a["token"],
|
|
"escape-a/alpha.txt",
|
|
)
|
|
resolved_b = resolve_authorized_escape_request(
|
|
workspace,
|
|
"sess-1",
|
|
grant_b["token"],
|
|
"escape-b/beta.txt",
|
|
)
|
|
|
|
assert resolved_a["target"] == outside_a / "alpha.txt"
|
|
assert resolved_b["target"] == outside_b / "beta.txt"
|
|
|
|
|
|
def test_authorized_listing_keeps_nested_child_escape_display_only(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
second_outside = tmp_path / "second-outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
second_outside.mkdir()
|
|
(workspace / "escape").symlink_to(outside)
|
|
(outside / "nested-escape").symlink_to(second_outside)
|
|
|
|
grant = authorize_escape_target(workspace, "sess-1", "escape")
|
|
payload = list_authorized_escape_dir(workspace, "sess-1", grant["token"], "escape")
|
|
entries = {entry["name"]: entry for entry in payload["entries"]}
|
|
|
|
assert entries["nested-escape"]["path"] == "escape/nested-escape"
|
|
assert entries["nested-escape"]["target_outside_workspace"] is True
|
|
assert entries["nested-escape"]["escape_read_only"] is True
|
|
assert "target" not in entries["nested-escape"]
|
|
|
|
|
|
# ── TOCTOU hardening (#3398): a path that passes safe_resolve_ws() but is then
|
|
# swapped to an external symlink before the open must not read/list/write
|
|
# outside the workspace. The read/list/write paths use a portable anchored
|
|
# openat-walk (openat + O_NOFOLLOW per component, dir_fd where supported). ──
|
|
|
|
|
|
def test_read_file_toctou_swap_to_external_symlink_blocked(tmp_path, monkeypatch):
|
|
"""If the resolved path is swapped to an external symlink AFTER the
|
|
safe_resolve_ws() check, read_file_content must refuse, not follow the
|
|
symlink and leak external content."""
|
|
import api.workspace as w
|
|
if not w._DIR_FD_OK:
|
|
pytest.skip("TOCTOU symlink-swap hardening requires dir_fd support")
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "data.txt").write_text("LEGIT", encoding="utf-8")
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_text("SECRET-LEAK", encoding="utf-8")
|
|
|
|
real_resolve = w.safe_resolve_ws
|
|
|
|
def racing_resolve(root, rel):
|
|
p = real_resolve(root, rel)
|
|
if rel == "data.txt":
|
|
try:
|
|
p.unlink()
|
|
except OSError:
|
|
pass
|
|
p.symlink_to(outside / "secret.txt")
|
|
return p
|
|
|
|
monkeypatch.setattr(w, "safe_resolve_ws", racing_resolve)
|
|
try:
|
|
result = w.read_file_content(workspace, "data.txt")
|
|
assert "SECRET" not in result["content"], "TOCTOU symlink swap leaked external content"
|
|
except (FileNotFoundError, ValueError):
|
|
pass # refused — the correct outcome
|
|
|
|
|
|
def test_list_dir_toctou_swap_to_external_symlink_blocked(tmp_path, monkeypatch):
|
|
"""If a checked directory path is swapped to an external symlink after
|
|
safe_resolve_ws(), list_dir must refuse rather than enumerate the external
|
|
directory."""
|
|
import api.workspace as w
|
|
if not w._DIR_FD_OK:
|
|
pytest.skip("TOCTOU symlink-swap hardening requires dir_fd support")
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "sub").mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_text("x", encoding="utf-8")
|
|
|
|
real_resolve = w.safe_resolve_ws
|
|
|
|
def racing_resolve(root, rel):
|
|
p = real_resolve(root, rel)
|
|
if rel == "sub":
|
|
try:
|
|
p.rmdir()
|
|
except OSError:
|
|
pass
|
|
p.symlink_to(outside)
|
|
return p
|
|
|
|
monkeypatch.setattr(w, "safe_resolve_ws", racing_resolve)
|
|
try:
|
|
entries = w.list_dir(workspace, "sub")
|
|
names = {e["name"] for e in entries}
|
|
assert "secret.txt" not in names, "TOCTOU symlink swap leaked external dir listing"
|
|
except (FileNotFoundError, ValueError):
|
|
pass # refused — the correct outcome
|
|
|
|
|
|
def test_anchored_create_blocks_symlinked_component(tmp_path):
|
|
"""open_anchored_create_fd must refuse to write through a symlinked path
|
|
component (the upload / archive-extraction write race), landing nothing
|
|
outside the workspace."""
|
|
import api.workspace as w
|
|
if not w._DIR_FD_OK:
|
|
pytest.skip("anchored symlink-component rejection requires dir_fd support")
|
|
from api.workspace import open_anchored_create_fd
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(workspace / "evil").symlink_to(outside) # symlinked intermediate dir
|
|
|
|
with pytest.raises((FileNotFoundError, ValueError, OSError)):
|
|
open_anchored_create_fd(workspace, (workspace / "evil" / "pwned.txt"))
|
|
assert not (outside / "pwned.txt").exists()
|
|
|
|
|
|
def test_anchored_create_no_fd_leak_on_rejection(tmp_path):
|
|
"""Repeated rejected anchored creates must not leak file descriptors."""
|
|
import os
|
|
|
|
from api.workspace import open_anchored_create_fd
|
|
|
|
if not os.path.isdir("/proc/self/fd"):
|
|
pytest.skip("fd-count check requires /proc/self/fd")
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(workspace / "evil").symlink_to(outside)
|
|
|
|
before = len(os.listdir("/proc/self/fd"))
|
|
for _ in range(200):
|
|
try:
|
|
open_anchored_create_fd(workspace, (workspace / "evil" / "x.txt"))
|
|
except Exception:
|
|
pass
|
|
after = len(os.listdir("/proc/self/fd"))
|
|
assert after <= before + 2, f"fd leak: before={before} after={after}"
|
|
|
|
|
|
def test_anchored_create_nested_autocreates_dirs(tmp_path):
|
|
"""A normal (non-escaping) nested create works and lands under the workspace."""
|
|
import os
|
|
|
|
from api.workspace import open_anchored_create_fd
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
fd = open_anchored_create_fd(workspace, workspace / "a" / "b" / "file.txt")
|
|
os.write(fd, b"hello")
|
|
os.close(fd)
|
|
assert (workspace / "a" / "b" / "file.txt").read_text() == "hello"
|
|
|
|
|
|
def test_rename_anchored_reports_destination_traversal(tmp_path):
|
|
from api.workspace import rename_anchored
|
|
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
source = workspace / "inside.txt"
|
|
source.write_text("inside", encoding="utf-8")
|
|
dest = outside / "outside.txt"
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
rename_anchored(workspace, source, dest)
|
|
assert str(dest) in str(exc_info.value)
|
|
|
|
|
|
def test_list_read_create_work_on_no_dir_fd_fallback(tmp_path, monkeypatch):
|
|
"""The no-dir_fd portability fallback (Windows path) must still list, read,
|
|
and create within the workspace, and still hide/block external symlinks via
|
|
the static safe_resolve_ws guard — no fd-relative API that would brick on
|
|
platforms without os.supports_dir_fd."""
|
|
import os
|
|
|
|
import api.workspace as w
|
|
|
|
monkeypatch.setattr(w, "_DIR_FD_OK", False)
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "a.txt").write_text("hi", encoding="utf-8")
|
|
(workspace / "sub").mkdir()
|
|
(workspace / "internal").symlink_to(workspace / "sub")
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "s.txt").write_text("x", encoding="utf-8")
|
|
(workspace / "escape").symlink_to(outside)
|
|
|
|
names = {e["name"] for e in w.list_dir(workspace, ".")}
|
|
assert "a.txt" in names
|
|
if w._DIR_FD_OK:
|
|
assert "internal" in names # legit internal symlink listed
|
|
assert "escape" in names # external symlink emitted (display-only)
|
|
escape_entry = next(e for e in w.list_dir(workspace, ".") if e["name"] == "escape")
|
|
assert escape_entry["target_outside_workspace"] is True
|
|
assert w.read_file_content(workspace, "a.txt")["content"] == "hi"
|
|
|
|
fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt")
|
|
os.write(fd, b"ok")
|
|
os.close(fd)
|
|
assert (workspace / "new" / "f.txt").read_text() == "ok"
|
|
|
|
|
|
def test_read_blocked_when_workspace_root_raced_to_symlink(tmp_path):
|
|
"""If the workspace root itself is swapped to an external symlink after
|
|
resolve() but before the anchored open, read_file_content must refuse
|
|
(O_NOFOLLOW on the root open), not follow it and leak external content."""
|
|
import os
|
|
import shutil
|
|
|
|
import api.workspace as w
|
|
|
|
if not w._DIR_FD_OK:
|
|
pytest.skip("anchored root-open race only applies on dir_fd platforms")
|
|
|
|
outside = tmp_path / "evil"
|
|
outside.mkdir()
|
|
(outside / "f.txt").write_text("SECRET-LEAK", encoding="utf-8")
|
|
wsroot = tmp_path / "wsroot"
|
|
wsroot.mkdir()
|
|
(wsroot / "f.txt").write_text("LEGIT", encoding="utf-8")
|
|
|
|
real_open = os.open
|
|
state = {"swapped": False}
|
|
|
|
def racing_open(path, *args, **kwargs):
|
|
if (not state["swapped"]) and "dir_fd" not in kwargs and str(path) == str(wsroot.resolve()):
|
|
state["swapped"] = True
|
|
shutil.rmtree(str(wsroot))
|
|
os.symlink(str(outside), str(wsroot))
|
|
return real_open(path, *args, **kwargs)
|
|
|
|
os.open = racing_open
|
|
try:
|
|
try:
|
|
result = w.read_file_content(wsroot, "f.txt")
|
|
assert "SECRET" not in result["content"], "root-swap race leaked external content"
|
|
except (FileNotFoundError, ValueError, NotADirectoryError, OSError):
|
|
pass # refused — correct
|
|
finally:
|
|
os.open = real_open
|
|
|
|
|
|
# ── #4510: escape-target symlinks are now emitted as display-only rows ──────
|
|
# The escape filter was widened (not removed): symlinks whose resolved target
|
|
# sits outside the workspace root are now emitted with
|
|
# target_outside_workspace=True instead of being silently dropped. The
|
|
# read/list gate (safe_resolve_ws / open_anchored_fd) is unchanged and still
|
|
# blocks navigation through them. ──────────────────────────────────────────
|
|
|
|
|
|
def test_list_dir_in_workspace_symlink_shape(tmp_path):
|
|
"""In-workspace symlinks emit type='symlink' with target_outside_workspace=False."""
|
|
import api.workspace as w
|
|
|
|
if not w._DIR_FD_OK:
|
|
pytest.skip("symlink listing requires dir_fd support")
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "data.txt").write_text("hello", encoding="utf-8")
|
|
(workspace / "link.txt").symlink_to(workspace / "data.txt")
|
|
|
|
entries = {e["name"]: e for e in w.list_dir(workspace, ".")}
|
|
assert "link.txt" in entries
|
|
assert entries["link.txt"]["type"] == "symlink"
|
|
assert entries["link.txt"]["target_outside_workspace"] is False
|
|
assert entries["link.txt"]["is_dir"] is False
|
|
assert entries["link.txt"]["target"] == str((workspace / "data.txt").resolve())
|
|
|
|
|
|
def test_list_dir_outside_workspace_symlink_emitted_with_flag(tmp_path):
|
|
"""Escape-target symlinks are emitted with target_outside_workspace=True."""
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
(outside / "file.txt").write_text("external", encoding="utf-8")
|
|
(workspace / "ext-link.txt").symlink_to(outside / "file.txt")
|
|
|
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
|
assert "ext-link.txt" in entries
|
|
assert entries["ext-link.txt"]["type"] == "symlink"
|
|
assert entries["ext-link.txt"]["target_outside_workspace"] is True
|
|
assert entries["ext-link.txt"]["is_dir"] is False
|
|
# #4581 hardening: a display-only escape-target row must NOT disclose where it
|
|
# points — no resolved outside path, no target-derived size, no target-derived
|
|
# metadata. Only the link name/path + the display-only flag are emitted.
|
|
assert "target" not in entries["ext-link.txt"]
|
|
assert "size" not in entries["ext-link.txt"]
|
|
|
|
|
|
def test_list_dir_external_symlink_blocked_system_path_unchanged(tmp_path):
|
|
"""Symlinks to blocked system paths (/etc, /usr) are still filtered out."""
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "etc-link").symlink_to("/etc")
|
|
(workspace / "usr-link").symlink_to("/usr")
|
|
|
|
names = {e["name"] for e in list_dir(workspace, ".")}
|
|
assert "etc-link" not in names
|
|
assert "usr-link" not in names
|
|
|
|
|
|
def test_list_dir_escape_symlink_read_still_blocked(tmp_path):
|
|
"""Listing shows the escape symlink (display-only) but read_file_content
|
|
on the same target still raises ValueError — proving the read gate is intact."""
|
|
workspace = tmp_path / "workspace"
|
|
outside = tmp_path / "outside"
|
|
workspace.mkdir()
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
|
(workspace / "escape.txt").symlink_to(outside / "secret.txt")
|
|
|
|
# Listing emits the entry with target_outside_workspace=True
|
|
entries = {e["name"]: e for e in list_dir(workspace, ".")}
|
|
assert "escape.txt" in entries
|
|
assert entries["escape.txt"]["target_outside_workspace"] is True
|
|
|
|
# But reading through it is still blocked
|
|
with pytest.raises(ValueError, match="Path traversal blocked"):
|
|
read_file_content(workspace, "escape.txt")
|