The managed-files API (#57505) confines locked-dashboard reads through _resolve_managed_path, but the file-preview and git-review routes resolve through unconfined _fs_path / _git_path. On a locked deployment those routes still returned auth.json / .env / mcp-tokens and any path outside the managed root (including via git diff --no-index on an absolute file argument). Apply the same locked-root and sensitive-path checks to: - /api/fs/list, /api/fs/read-text, /api/fs/read-data-url - /api/git/review/diff and /api/git/file-diff (resolved file target) - /api/git/review/commit-context (caller-supplied repo cwd; this route returns git diff --cached / git diff HEAD with no per-file arg, so the cwd itself is the read target) Local dashboards with no locked root are unchanged. Regression tests cover credential and outside-root blocks plus in-root allow paths, including a locked-mode outside-root case for commit-context. (cherry picked from commit b6ed59823f22fc537f123407a01915135dd6d5fe)
99 lines
4.1 KiB
Python
99 lines
4.1 KiB
Python
"""Locked preview and review routes share the managed-file read boundary."""
|
|
|
|
import subprocess
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from hermes_cli import web_server
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
|
|
with TestClient(web_server.app) as client:
|
|
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
|
yield client
|
|
|
|
|
|
@pytest.mark.parametrize("locked", [True, False])
|
|
def test_preview_respects_locked_root_and_sensitive_paths(client, monkeypatch, tmp_path, locked):
|
|
root = tmp_path / "root"
|
|
root.mkdir()
|
|
if locked:
|
|
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
|
|
else:
|
|
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
|
safe = root / "safe.txt"
|
|
secret = root / "auth.json"
|
|
outside = tmp_path / "outside.txt"
|
|
for path in (safe, secret, outside):
|
|
path.write_text("example", encoding="utf-8")
|
|
for endpoint in ("read-text", "read-data-url", "download"):
|
|
for path in (safe, secret, outside):
|
|
response = client.get(f"/api/fs/{endpoint}", params={"path": str(path)})
|
|
denied = path == secret or (locked and path == outside)
|
|
assert response.status_code == (403 if denied else 200)
|
|
response = client.get("/api/fs/list", params={"path": str(root)})
|
|
names = {entry["name"] for entry in response.json()["entries"]}
|
|
assert "safe.txt" in names
|
|
assert "auth.json" not in names
|
|
assert client.get("/api/fs/list", params={"path": str(tmp_path)}).status_code == (403 if locked else 200)
|
|
|
|
|
|
@pytest.mark.parametrize("locked", [True, False])
|
|
@pytest.mark.parametrize("change", ["staged", "unstaged", "deleted", "renamed"])
|
|
def test_git_reads_filter_credentials_without_losing_safe_diffs(
|
|
client, monkeypatch, tmp_path, locked, change
|
|
):
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
|
|
def git(*args):
|
|
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
|
|
|
|
git("init", "-q")
|
|
git("config", "user.email", "test@example.com")
|
|
git("config", "user.name", "Test")
|
|
secret = root / ".env"
|
|
secret.write_text("PRIVATE_EXAMPLE=old\n", encoding="utf-8")
|
|
archive = root / "archive"
|
|
archive.mkdir()
|
|
(archive / "auth.json").write_text("PRIVATE_EXAMPLE=archived\n", encoding="utf-8")
|
|
(root / "safe.txt").write_text("old\n", encoding="utf-8")
|
|
git("add", "-A")
|
|
git("commit", "-qm", "initial")
|
|
(archive / "auth.json").unlink()
|
|
archive.rmdir()
|
|
(root / "safe.txt").write_text("SAFE_CHANGE\n", encoding="utf-8")
|
|
if change == "deleted":
|
|
secret.unlink()
|
|
elif change == "renamed":
|
|
git("mv", ".env", "auth.json")
|
|
(root / "auth.json").write_text("PRIVATE_EXAMPLE=new\n", encoding="utf-8")
|
|
else:
|
|
secret.write_text("PRIVATE_EXAMPLE=new\n", encoding="utf-8")
|
|
if change != "unstaged":
|
|
git("add", "-A")
|
|
if locked:
|
|
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
|
|
else:
|
|
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
|
|
|
response = client.get("/api/git/review/commit-context", params={"path": str(root)})
|
|
assert response.status_code == 200
|
|
diff = response.json()["diff"]
|
|
assert "SAFE_CHANGE" in diff
|
|
assert ("PRIVATE_EXAMPLE" in diff) is not locked
|
|
for endpoint in ("/api/git/file-diff", "/api/git/review/diff"):
|
|
response = client.get(endpoint, params={"path": str(root), "file": ".env", "staged": change != "unstaged"})
|
|
assert response.status_code == (403 if locked else 200)
|
|
# A pathspec is not a file name and must not expand into credential files.
|
|
for file in (":(top)*", ".", "archive"):
|
|
response = client.get(endpoint, params={"path": str(root), "file": file, "staged": change != "unstaged"})
|
|
assert "PRIVATE_EXAMPLE" not in response.text
|
|
|
|
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(tmp_path / "other"))
|
|
response = client.get("/api/git/review/commit-context", params={"path": str(root)})
|
|
assert response.status_code == 403
|