security: enforce strict browser execution boundary (#295)
This commit is contained in:
1 parent
54029d2668
commit
6e6e63e553
28 files changed
+3435
-3287
No files matched your search
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.views import dashboard as dashboard_html
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
def dashboard_bundle_text() -> str:
|
||||
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
|
||||
return "\n".join(
|
||||
(
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# Exercise the real view lookup before adding its separately served assets.
|
||||
html = await dashboard_html()
|
||||
return "\n".join(
|
||||
(
|
||||
html,
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
@@ -2,12 +2,11 @@ import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = DASHBOARD_HTML.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
|
||||
assert api_paths
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
|
||||
|
||||
@@ -6,7 +6,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
|
||||
@@ -4,6 +4,8 @@ from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
@@ -41,7 +43,7 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
||||
|
||||
def test_command_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
parser.feed(dashboard_bundle_text())
|
||||
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
||||
|
||||
assert urljoin(
|
||||
@@ -152,7 +154,7 @@ if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys sho
|
||||
|
||||
|
||||
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
@@ -221,7 +223,7 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
||||
"function renderCommands", 1
|
||||
@@ -233,7 +235,7 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
||||
|
||||
@@ -6,13 +6,14 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
@@ -25,14 +26,14 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
|
||||
|
||||
def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "setEventStreamStatus('Update failed · showing last activity')" in html
|
||||
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_panels_are_independently_collapsible():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
|
||||
assert f'<details class="panel' in html
|
||||
@@ -13,7 +15,7 @@ def test_dashboard_panels_are_independently_collapsible():
|
||||
|
||||
|
||||
def test_panel_open_state_is_restored_and_saved_in_local_storage():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
|
||||
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
REQUIRED_HEADERS = {
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"x-frame-options": "DENY",
|
||||
}
|
||||
|
||||
|
||||
def assert_browser_security_boundary(response: httpx.Response) -> None:
|
||||
for name, value in REQUIRED_HEADERS.items():
|
||||
assert response.headers[name] == value
|
||||
assert "camera=()" in response.headers["permissions-policy"]
|
||||
policy = response.headers["content-security-policy"]
|
||||
assert "default-src 'self'" in policy
|
||||
assert "script-src 'self'" in policy
|
||||
assert "object-src 'none'" in policy
|
||||
assert "frame-ancestors 'none'" in policy
|
||||
script_policy = next(part for part in policy.split(";") if "script-src" in part)
|
||||
assert "'unsafe-inline'" not in script_policy
|
||||
assert "'unsafe-eval'" not in script_policy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_boundary_covers_pages_health_static_and_api_errors(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
responses = [
|
||||
await client.get("/healthz"),
|
||||
await client.get("/login"),
|
||||
await client.get("/static/dashboard.js"),
|
||||
await client.get("/api/v1/context"),
|
||||
]
|
||||
|
||||
assert [response.status_code for response in responses] == [200, 200, 200, 401]
|
||||
for response in responses:
|
||||
assert_browser_security_boundary(response)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_boundary_covers_fail_closed_authentication_response(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/login")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert_browser_security_boundary(response)
|
||||
@@ -92,10 +92,12 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_mobile_install_flow_ships_in_a_new_shell_cache():
|
||||
def test_strict_browser_assets_ship_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v25" in source
|
||||
assert "stackchain-dashboard-shell-v26" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
||||
|
||||
@@ -196,6 +198,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
assert set(result["added"]) == {
|
||||
"/dashboard/",
|
||||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/dashboard.css",
|
||||
"/dashboard/static/dashboard.js",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
|
||||
+16
-3
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
@@ -21,15 +23,26 @@ async def test_dashboard_viewport_allows_mobile_zoom():
|
||||
assert "user-scalable=no" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_bootstrap_uses_csp_compatible_static_assets():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<link rel="stylesheet" href="static/dashboard.css" />' in html
|
||||
assert '<script src="static/dashboard.js"></script>' in html
|
||||
assert "<style>" not in html
|
||||
assert "<script>" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
html = await dashboard()
|
||||
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert 'src="static/search-preview.js"' in html
|
||||
assert ".search-preview-panel" in html
|
||||
assert "height:100dvh" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
assert ".search-preview-panel" in css
|
||||
assert "height:100dvh" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -98,9 +100,10 @@ async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
|
||||
assert 'id="offline-status"' in response.text
|
||||
assert 'role="status"' in response.text
|
||||
assert 'aria-live="polite"' in response.text
|
||||
assert "window.addEventListener('offline'" in response.text
|
||||
assert "window.addEventListener('online'" in response.text
|
||||
assert "contextPoller.refresh()" in response.text
|
||||
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "window.addEventListener('offline'" in bootstrap
|
||||
assert "window.addEventListener('online'" in bootstrap
|
||||
assert "contextPoller.refresh()" in bootstrap
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in new issue
Block a user