Files
hermes-webui/tests/test_issue4582_workspace_escape_navigation.py
T

423 lines
15 KiB
Python

from __future__ import annotations
import json
import pathlib
import re
import shutil
import subprocess
import urllib.error
import urllib.request
import urllib.parse
import pytest
from api.routes import _project_os_workspace_read
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parents[1]
UI_JS = ROOT / "static" / "ui.js"
WORKSPACE_JS = ROOT / "static" / "workspace.js"
NODE = shutil.which("node")
def _get_json(path: str) -> dict:
with urllib.request.urlopen(BASE + path, timeout=10) as response:
return json.loads(response.read())
def _get_bytes(path: str) -> bytes:
with urllib.request.urlopen(BASE + path, timeout=10) as response:
return response.read()
def _browser_headers() -> dict[str, str]:
parsed = urllib.parse.urlparse(BASE)
return {"Origin": f"{parsed.scheme}://{parsed.netloc}"}
def _referer_only_headers() -> dict[str, str]:
parsed = urllib.parse.urlparse(BASE)
return {"Referer": f"{parsed.scheme}://{parsed.netloc}/workspace"}
def _post_json(path: str, body: dict | None = None, headers: dict[str, str] | None = None) -> tuple[dict, int]:
req = urllib.request.Request(
BASE + path,
data=json.dumps(body or {}).encode(),
headers={"Content-Type": "application/json", **(headers or {})},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
return json.loads(response.read()), response.status
except urllib.error.HTTPError as exc:
return json.loads(exc.read()), exc.code
def _make_session(workspace: pathlib.Path) -> str:
_post_json("/api/workspaces/add", {"path": str(workspace)})
payload, status = _post_json("/api/session/new", {"workspace": str(workspace)})
assert status == 200, payload
return payload["session"]["session_id"]
def _read_workspace_js() -> str:
return WORKSPACE_JS.read_text(encoding="utf-8")
def _workspace_escape_helper_block() -> str:
src = _read_workspace_js()
start = src.find("function _escapeGrantStore(){")
assert start >= 0, "escape grant helper block start not found in static/workspace.js"
end = src.find("let _workspacePanelActiveTab = 'files';", start)
assert end >= 0, "escape grant helper block end not found in static/workspace.js"
return src[start:end]
def _run_node(js: str) -> dict:
assert NODE is not None, "node not on PATH"
completed = subprocess.run(
[NODE, "-e", js],
cwd=ROOT,
capture_output=True,
text=True,
check=True,
)
return json.loads(completed.stdout)
class TestIssue4582EscapeNavigationLive:
def test_authorized_file_symlink_reads_and_raws_through_parent_anchor(self, tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(outside / "note.txt").write_text("outside file", encoding="utf-8")
(workspace / "escape-file.txt").symlink_to(outside / "note.txt")
sid = _make_session(workspace)
root_listing = _get_json(f"/api/list?session_id={sid}&path=.")
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape-file.txt"]
assert escape_row["target_outside_workspace"] is True
denied, denied_status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape-file.txt"},
)
assert denied_status == 403, denied
referer_only, referer_only_status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape-file.txt"},
headers=_referer_only_headers(),
)
assert referer_only_status == 403, referer_only
auth, status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape-file.txt"},
headers=_browser_headers(),
)
assert status == 200, auth
assert auth["path"] == "escape-file.txt"
assert auth["is_dir"] is False
assert auth["read_only"] is True
text = _get_json(
f"/api/escape/file/read?session_id={sid}&token={auth['token']}&path=escape-file.txt"
)
assert text["path"] == "escape-file.txt"
assert text["content"] == "outside file"
assert text["escape_read_only"] is True
raw = _get_bytes(
f"/api/escape/file/raw?session_id={sid}&token={auth['token']}&path=escape-file.txt"
)
assert raw == b"outside file"
try:
_get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape-file.txt"
)
assert False, "file escape grants should not list as directories"
except urllib.error.HTTPError as exc:
assert exc.code in (403, 404)
def test_authorized_dir_list_read_and_raw_stay_virtualized(self, tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(outside / "note.txt").write_text("outside note", encoding="utf-8")
(workspace / "escape").symlink_to(outside)
sid = _make_session(workspace)
root_listing = _get_json(f"/api/list?session_id={sid}&path=.")
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape"]
assert escape_row["target_outside_workspace"] is True
denied, denied_status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
assert denied_status == 403, denied
referer_only, referer_only_status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape"},
headers=_referer_only_headers(),
)
assert referer_only_status == 403, referer_only
auth, status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape"},
headers=_browser_headers(),
)
assert status == 200, auth
assert auth["path"] == "escape"
assert auth["is_dir"] is True
assert auth["read_only"] is True
listed = _get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape"
)
entries = {entry["name"]: entry for entry in listed["entries"]}
assert listed["path"] == "escape"
assert listed["read_only"] is True
assert entries["note.txt"]["path"] == "escape/note.txt"
assert entries["note.txt"]["escape_read_only"] is True
assert str(outside) not in json.dumps(listed)
text = _get_json(
f"/api/escape/file/read?session_id={sid}&token={auth['token']}&path=escape/note.txt"
)
assert text["path"] == "escape/note.txt"
assert text["content"] == "outside note"
assert text["escape_read_only"] is True
raw = _get_bytes(
f"/api/escape/file/raw?session_id={sid}&token={auth['token']}&path=escape/note.txt"
)
assert raw == b"outside note"
assert _project_os_workspace_read(pathlib.Path(workspace), "escape/note.txt") is None
def test_nested_escape_row_stays_display_only_and_non_browsable(self, tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
second_outside = tmp_path / "second-outside"
workspace.mkdir()
outside.mkdir()
second_outside.mkdir()
(second_outside / "secret.txt").write_text("secret", encoding="utf-8")
(outside / "nested-escape").symlink_to(second_outside)
(outside / "nested-file-escape.txt").symlink_to(second_outside / "secret.txt")
(workspace / "escape").symlink_to(outside)
sid = _make_session(workspace)
auth, status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape"},
headers=_browser_headers(),
)
assert status == 200, auth
nested_auth, nested_status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape/nested-escape"},
headers=_browser_headers(),
)
assert nested_status in (403, 404), nested_auth
listed = _get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape"
)
entries = {entry["name"]: entry for entry in listed["entries"]}
nested = entries["nested-escape"]
assert nested["target_outside_workspace"] is True
assert nested["escape_read_only"] is True
assert "target" not in nested
try:
_get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape/nested-escape"
)
assert False, "nested escape traversal should stay blocked"
except urllib.error.HTTPError as exc:
assert exc.code in (403, 404)
try:
_get_bytes(
f"/api/escape/file/raw?session_id={sid}&token={auth['token']}&path=escape/nested-file-escape.txt"
)
assert False, "nested file escape raw read should stay blocked"
except urllib.error.HTTPError as exc:
assert exc.code in (403, 404)
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
class TestIssue4582EscapeNavigationFrontend:
def test_authorization_flow_switches_routes_and_marks_subtree_read_only(self):
helper_block = _workspace_escape_helper_block()
js = (
"const helperBlock = "
+ json.dumps(helper_block)
+ ";\n"
+ r"""
const S = { session: { session_id: 'sess-1' }, currentDir: '.', _escapeGrants: Object.create(null) };
const confirmCalls = [];
const apiCalls = [];
const toasts = [];
const showConfirmDialog = async (opts) => { confirmCalls.push(opts); return true; };
const api = async (path, opts) => {
apiCalls.push({ path, method: opts && opts.method, body: opts && opts.body });
return {
token: 'tok-123',
path: 'escape',
expires_at: 4102444800,
is_dir: true,
read_only: true,
};
};
const showToast = (...args) => { toasts.push(args); };
const t = (key) => key;
const runner = new Function(
'S', 'showConfirmDialog', 'api', 'showToast', 't', 'URLSearchParams',
helperBlock + '; return { authorizeWorkspaceEscapeNavigation, _workspaceRouteForPath, _workspacePathIsReadOnly, _workspaceEscapeGrantForPath };'
);
const apiFns = runner(S, showConfirmDialog, api, showToast, t, URLSearchParams);
(async () => {
const beforeRead = apiFns._workspaceRouteForPath('escape/note.txt', 'read');
const beforeList = apiFns._workspaceRouteForPath('escape', 'list');
const grant = await apiFns.authorizeWorkspaceEscapeNavigation({ path: 'escape', name: 'escape' });
const afterRead = apiFns._workspaceRouteForPath('escape/note.txt', 'read');
const afterRaw = apiFns._workspaceRouteForPath('escape/note.txt', 'raw', { inline: true });
const grantLookup = apiFns._workspaceEscapeGrantForPath('escape/note.txt');
const readOnly = apiFns._workspacePathIsReadOnly('escape/note.txt');
console.log(JSON.stringify({
beforeRead,
beforeList,
afterRead,
afterRaw,
grant,
grantLookup,
readOnly,
confirmCalls,
apiCalls,
toasts,
}));
})().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
"""
)
result = _run_node(js)
assert result["beforeRead"] == "/api/file?session_id=sess-1&path=escape%2Fnote.txt"
assert result["beforeList"] == "/api/list?session_id=sess-1&path=escape"
assert result["afterRead"] == "/api/escape/file/read?session_id=sess-1&path=escape%2Fnote.txt&token=tok-123"
assert result["afterRaw"] == "/api/escape/file/raw?session_id=sess-1&path=escape%2Fnote.txt&token=tok-123&inline=1"
assert result["grant"]["path"] == "escape"
assert result["grantLookup"]["token"] == "tok-123"
assert result["readOnly"] is True
assert result["confirmCalls"][0]["message"] == "external_link_open_confirm"
assert result["apiCalls"] == [
{
"path": "/api/escape/authorize",
"method": "POST",
"body": "{\"session_id\":\"sess-1\",\"path\":\"escape\"}",
}
]
assert result["toasts"][0][0] == "external_link_read_only"
def test_exact_grant_click_reauthorizes_without_reprompt(self):
helper_block = _workspace_escape_helper_block()
js = (
"const helperBlock = "
+ json.dumps(helper_block)
+ ";\n"
+ r"""
const S = {
session: { session_id: 'sess-1' },
currentDir: '.',
_escapeGrants: {
escape: {
sessionId: 'sess-1',
path: 'escape',
token: 'tok-old',
expiresAt: Date.now() + 60_000,
isDir: true,
},
},
};
const confirmCalls = [];
const apiCalls = [];
const toasts = [];
const showConfirmDialog = async (opts) => { confirmCalls.push(opts); return true; };
const api = async (path, opts) => {
apiCalls.push({ path, method: opts && opts.method, body: opts && opts.body });
return {
token: 'tok-new',
path: 'escape',
expires_at: 4102444800,
is_dir: true,
read_only: true,
};
};
const showToast = (...args) => { toasts.push(args); };
const t = (key) => key;
const runner = new Function(
'S', 'showConfirmDialog', 'api', 'showToast', 't', 'URLSearchParams',
helperBlock + '; return { authorizeWorkspaceEscapeNavigation, _workspaceEscapeExactGrant };'
);
const apiFns = runner(S, showConfirmDialog, api, showToast, t, URLSearchParams);
(async () => {
const grant = await apiFns.authorizeWorkspaceEscapeNavigation({ path: 'escape', name: 'escape' });
console.log(JSON.stringify({
grant,
stored: apiFns._workspaceEscapeExactGrant('escape'),
confirmCalls,
apiCalls,
toasts,
}));
})().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
"""
)
result = _run_node(js)
assert result["grant"]["token"] == "tok-new"
assert result["stored"]["token"] == "tok-new"
assert result["confirmCalls"] == []
assert result["apiCalls"] == [
{
"path": "/api/escape/authorize",
"method": "POST",
"body": "{\"session_id\":\"sess-1\",\"path\":\"escape\"}",
}
]
assert result["toasts"][0][0] == "external_link_read_only"
def test_external_rows_authorize_then_open(self):
src = UI_JS.read_text(encoding="utf-8")
assert "authorizeWorkspaceEscapeNavigation(item)" in src
assert "if(grant.isDir) await loadDir(item.path);" in src
assert "else await openFile(item.path);" in src
def test_read_only_affordances_stay_suppressed(self):
ui_src = UI_JS.read_text(encoding="utf-8")
ws_src = _read_workspace_js()
assert "if(!isReadOnlyEscape){" in ui_src
assert "_workspacePathIsReadOnly(_previewCurrentPath)" in ws_src
assert "_workspacePathIsReadOnly(S.currentDir || '.')" in ws_src
def test_open_in_browser_reuses_workspace_route_helper(self):
ws_src = _read_workspace_js()
match = re.search(
r"function openInBrowser\(\)\{\s*if\(!_previewCurrentPath\|\|!S\.session\) return;\s*const url=(.*?);\s*window\.open\(url,'_blank','noopener'\);\s*\}",
ws_src,
re.DOTALL,
)
assert match, "openInBrowser helper not found"
assert "_workspaceRouteForPath(_previewCurrentPath, 'raw', {inline:true})" in match.group(1)