Keep escape grants browser-confirmed and refreshable (#4582)
This commit is contained in:
1 parent
e8c4cb85c3
commit
decb1037bc
4 files changed
+119
-15
No files matched your search
@@ -12949,6 +12949,10 @@ def _read_json_request_body(handler, *, max_bytes: int = 4096) -> dict:
|
|||||||
def _handle_escape_authorize(handler, parsed, body: dict | None = None):
|
def _handle_escape_authorize(handler, parsed, body: dict | None = None):
|
||||||
if handler.command != "POST":
|
if handler.command != "POST":
|
||||||
return bad(handler, "method not allowed", 405)
|
return bad(handler, "method not allowed", 405)
|
||||||
|
if not _is_browser_unsafe_request(handler):
|
||||||
|
return bad(handler, "browser confirmation required", 403)
|
||||||
|
if not _check_csrf(handler):
|
||||||
|
return bad(handler, _csrf_rejection_error(handler), 403)
|
||||||
if body is None:
|
if body is None:
|
||||||
try:
|
try:
|
||||||
body = _read_json_request_body(handler)
|
body = _read_json_request_body(handler)
|
||||||
|
|||||||
+11
-10
@@ -252,16 +252,17 @@ async function authorizeWorkspaceEscapeNavigation(item){
|
|||||||
if(!S.session || !item || !item.path) return null;
|
if(!S.session || !item || !item.path) return null;
|
||||||
const normalizedPath = _normalizeWorkspaceRelPath(item.path);
|
const normalizedPath = _normalizeWorkspaceRelPath(item.path);
|
||||||
const exactGrant = _workspaceEscapeExactGrant(normalizedPath);
|
const exactGrant = _workspaceEscapeExactGrant(normalizedPath);
|
||||||
if(exactGrant) return exactGrant;
|
if(!exactGrant){
|
||||||
const ok = await showConfirmDialog({
|
const ok = await showConfirmDialog({
|
||||||
title: item.name || normalizedPath,
|
title: item.name || normalizedPath,
|
||||||
message: t('external_link_open_confirm'),
|
message: t('external_link_open_confirm'),
|
||||||
confirmLabel: t('dialog_confirm_btn'),
|
confirmLabel: t('dialog_confirm_btn'),
|
||||||
danger: false,
|
danger: false,
|
||||||
hideCancel: true,
|
hideCancel: true,
|
||||||
focusCancel: false,
|
focusCancel: false,
|
||||||
});
|
});
|
||||||
if(!ok) return null;
|
if(!ok) return null;
|
||||||
|
}
|
||||||
try{
|
try{
|
||||||
const data = await api('/api/escape/authorize', {
|
const data = await api('/api/escape/authorize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -30,11 +31,16 @@ def _get_bytes(path: str) -> bytes:
|
|||||||
return response.read()
|
return response.read()
|
||||||
|
|
||||||
|
|
||||||
def _post_json(path: str, body: dict | None = None) -> tuple[dict, int]:
|
def _browser_headers() -> dict[str, str]:
|
||||||
|
parsed = urllib.parse.urlparse(BASE)
|
||||||
|
return {"Origin": f"{parsed.scheme}://{parsed.netloc}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _post_json(path: str, body: dict | None = None, headers: dict[str, str] | None = None) -> tuple[dict, int]:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
BASE + path,
|
BASE + path,
|
||||||
data=json.dumps(body or {}).encode(),
|
data=json.dumps(body or {}).encode(),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json", **(headers or {})},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as response:
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||||||
@@ -89,9 +95,16 @@ class TestIssue4582EscapeNavigationLive:
|
|||||||
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape-file.txt"]
|
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape-file.txt"]
|
||||||
assert escape_row["target_outside_workspace"] is True
|
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
|
||||||
|
|
||||||
auth, status = _post_json(
|
auth, status = _post_json(
|
||||||
"/api/escape/authorize",
|
"/api/escape/authorize",
|
||||||
{"session_id": sid, "path": "escape-file.txt"},
|
{"session_id": sid, "path": "escape-file.txt"},
|
||||||
|
headers=_browser_headers(),
|
||||||
)
|
)
|
||||||
assert status == 200, auth
|
assert status == 200, auth
|
||||||
assert auth["path"] == "escape-file.txt"
|
assert auth["path"] == "escape-file.txt"
|
||||||
@@ -131,7 +144,14 @@ class TestIssue4582EscapeNavigationLive:
|
|||||||
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape"]
|
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape"]
|
||||||
assert escape_row["target_outside_workspace"] is True
|
assert escape_row["target_outside_workspace"] is True
|
||||||
|
|
||||||
auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
|
denied, denied_status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
|
||||||
|
assert denied_status == 403, denied
|
||||||
|
|
||||||
|
auth, status = _post_json(
|
||||||
|
"/api/escape/authorize",
|
||||||
|
{"session_id": sid, "path": "escape"},
|
||||||
|
headers=_browser_headers(),
|
||||||
|
)
|
||||||
assert status == 200, auth
|
assert status == 200, auth
|
||||||
assert auth["path"] == "escape"
|
assert auth["path"] == "escape"
|
||||||
assert auth["is_dir"] is True
|
assert auth["is_dir"] is True
|
||||||
@@ -173,12 +193,17 @@ class TestIssue4582EscapeNavigationLive:
|
|||||||
(workspace / "escape").symlink_to(outside)
|
(workspace / "escape").symlink_to(outside)
|
||||||
|
|
||||||
sid = _make_session(workspace)
|
sid = _make_session(workspace)
|
||||||
auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
|
auth, status = _post_json(
|
||||||
|
"/api/escape/authorize",
|
||||||
|
{"session_id": sid, "path": "escape"},
|
||||||
|
headers=_browser_headers(),
|
||||||
|
)
|
||||||
assert status == 200, auth
|
assert status == 200, auth
|
||||||
|
|
||||||
nested_auth, nested_status = _post_json(
|
nested_auth, nested_status = _post_json(
|
||||||
"/api/escape/authorize",
|
"/api/escape/authorize",
|
||||||
{"session_id": sid, "path": "escape/nested-escape"},
|
{"session_id": sid, "path": "escape/nested-escape"},
|
||||||
|
headers=_browser_headers(),
|
||||||
)
|
)
|
||||||
assert nested_status in (403, 404), nested_auth
|
assert nested_status in (403, 404), nested_auth
|
||||||
|
|
||||||
@@ -285,6 +310,75 @@ const apiFns = runner(S, showConfirmDialog, api, showToast, t, URLSearchParams);
|
|||||||
]
|
]
|
||||||
assert result["toasts"][0][0] == "external_link_read_only"
|
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):
|
def test_external_rows_authorize_then_open(self):
|
||||||
src = UI_JS.read_text(encoding="utf-8")
|
src = UI_JS.read_text(encoding="utf-8")
|
||||||
assert "authorizeWorkspaceEscapeNavigation(item)" in src
|
assert "authorizeWorkspaceEscapeNavigation(item)" in src
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ recursion. Covers:
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -33,8 +34,12 @@ def get(path):
|
|||||||
def post(path, body=None):
|
def post(path, body=None):
|
||||||
url = BASE + path
|
url = BASE + path
|
||||||
data = json.dumps(body or {}).encode()
|
data = json.dumps(body or {}).encode()
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if path == "/api/escape/authorize":
|
||||||
|
parsed = urllib.parse.urlparse(BASE)
|
||||||
|
headers["Origin"] = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
req = urllib.request.Request(url, data=data,
|
req = urllib.request.Request(url, data=data,
|
||||||
headers={"Content-Type": "application/json"})
|
headers=headers)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
return json.loads(r.read()), r.status
|
return json.loads(r.read()), r.status
|
||||||
|
|||||||
Reference in new issue
Block a user