423 lines
15 KiB
Python
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)
|