feat: require operator sessions for privileged access (#258)
This commit is contained in:
1 parent
e40c75b360
commit
39357263d0
10 files changed
+655
-25
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch):
|
||||
called = False
|
||||
|
||||
async def user():
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/api/v1/context")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Authentication required"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"authenticated": True}
|
||||
cookies = response.headers.get_list("set-cookie")
|
||||
assert any("stackchain_session=" in value and "HttpOnly" in value and "Secure" in value and "SameSite=strict" in value for value in cookies)
|
||||
assert any("stackchain_csrf=" in value and "Secure" in value and "SameSite=strict" in value and "HttpOnly" not in value for value in cookies)
|
||||
assert "correct horse battery staple" not in response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy", "full_name": "", "email": ""}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
signed_in = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
response = await client.get("/api/v1/context")
|
||||
|
||||
assert signed_in.status_code == 200
|
||||
assert response.status_code == 200
|
||||
assert response.json()["user"]["login"] == "timmy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"authenticated": True,
|
||||
"csrf_token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
assert "correct horse battery staple" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def mark_read(notification_id):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": notification_id, "read": True}
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
missing = await client.patch("/api/v1/notifications/7/read")
|
||||
foreign = await client.patch(
|
||||
"/api/v1/notifications/7/read",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
valid = await client.patch(
|
||||
"/api/v1/notifications/7/read",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
|
||||
assert missing.status_code == 403
|
||||
assert foreign.status_code == 403
|
||||
assert valid.status_code == 200
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
response = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
private = await client.get("/api/v1/background-identity")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"authenticated": False, "clear_private_device_data": True}
|
||||
assert private.status_code == 401
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
health = await client.get("/healthz")
|
||||
login = await client.get("/login")
|
||||
ready = await client.get("/readyz")
|
||||
|
||||
assert health.status_code == 200
|
||||
assert login.status_code == 200
|
||||
assert 'name="access_token"' in login.text
|
||||
assert ready.status_code == 200
|
||||
assert ready.json() == {"status": "ready", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subpath_deployment_scopes_routes_and_session_cookies(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, root_path="/dashboard")
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://test/dashboard/"
|
||||
) as client:
|
||||
login = await client.get("login")
|
||||
signed_in = await client.post(
|
||||
"api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
status = await client.get("api/v1/session")
|
||||
|
||||
assert login.status_code == 200
|
||||
assert signed_in.status_code == 200
|
||||
assert status.status_code == 200
|
||||
assert any(
|
||||
"stackchain_session=" in value and "Path=/dashboard" in value
|
||||
for value in signed_in.headers.get_list("set-cookie")
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
|
||||
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
|
||||
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
|
||||
}};
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
|
||||
origin: 'https://forge.example',
|
||||
base: '/dashboard/',
|
||||
fetchImpl: async (url, options = {{}}) => {{
|
||||
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
|
||||
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||
}},
|
||||
localStorage: storage,
|
||||
sessionStorage: storage,
|
||||
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||
}});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_sign_out_clears_only_dashboard_private_device_state():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.signOut();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
request = result["requests"][0]
|
||||
assert request["url"] == "/dashboard/api/v1/session"
|
||||
assert request["method"] == "DELETE"
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/session.js"></script>' in html
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
@@ -70,10 +70,10 @@ async function dispatchSync(tag) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_detail_defer_ships_in_a_new_shell_cache():
|
||||
def test_operator_session_boundary_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v15" in source
|
||||
assert "stackchain-dashboard-shell-v16" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -88,6 +88,14 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "async function sessionCsrf()" in source
|
||||
assert "BASE + 'api/v1/session'" in source
|
||||
assert "headers.set('X-CSRF-Token', csrf)" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -103,6 +111,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
|
||||
Reference in new issue
Block a user