security: require fresh authorization for high-impact actions (#341)
CI / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-08 21:04:28 +00:00
1 parent 5d326c30a1
commit 3726360baa
8 files changed
+597 -9

No files matched your search

+152 -1
View File
@@ -20,6 +20,23 @@ def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
async def fresh_grant(client, action: str, target: str) -> str:
response = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": action,
"target": target,
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert response.status_code == 201
return response.json()["grant"]
@pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
@@ -257,6 +274,126 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
):
merge_calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
merge_calls.append((repository, number, expected_head_sha))
return {"number": number, "merged": True, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
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_headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
missing = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
headers=csrf_headers,
)
authorized = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "merge_pull",
"target": "stackchain/api#7",
},
headers=csrf_headers,
)
grant_headers = {**csrf_headers, "X-Step-Up-Grant": authorized.json()["grant"]}
merged = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
headers=grant_headers,
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
headers=grant_headers,
)
assert missing.status_code == 428
assert missing.json() == {
"detail": {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "merge_pull",
"target": "stackchain/api#7",
}
}
assert authorized.status_code == 201
assert authorized.json()["expires_in"] == 90
assert merged.status_code == 200
assert replayed.status_code == 428
assert merge_calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_other_high_impact_routes_require_fresh_authorization_before_mutation(
access_control, monkeypatch
):
close_calls = []
async def assigned(repository, number):
return True
async def close(repository, number):
close_calls.append((repository, number))
return {"number": number, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
await laptop.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
remote = next(
item
for item in (await phone.get("/api/v1/sessions")).json()["devices"]
if not item["current"]
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
}
closed = await phone.patch(
"/api/v1/repos/stackchain/api/issues/7/close", headers=headers
)
revoked = await phone.delete(
f"/api/v1/sessions/{remote['management_id']}", headers=headers
)
revoked_all = await phone.delete("/api/v1/sessions", headers=headers)
laptop_still_active = await laptop.get("/api/v1/session")
assert [closed.status_code, revoked.status_code, revoked_all.status_code] == [428, 428, 428]
assert [closed.json()["detail"]["action"], revoked.json()["detail"]["action"], revoked_all.json()["detail"]["action"]] == [
"close_issue", "revoke_device", "revoke_all_sessions"
]
assert close_calls == []
assert laptop_still_active.status_code == 200
@pytest.mark.anyio
async def test_operator_can_review_and_revoke_one_remote_device(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -287,11 +424,15 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
missing_csrf = await laptop.delete(
f"/api/v1/sessions/{phone_device['management_id']}"
)
grant = await fresh_grant(
laptop, "revoke_device", phone_device["management_id"]
)
revoked = await laptop.delete(
f"/api/v1/sessions/{phone_device['management_id']}",
headers={
"Origin": "https://test",
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
phone_status = await phone.get("/api/v1/session")
@@ -401,11 +542,13 @@ async def test_sign_out_all_devices_revokes_every_existing_session(access_contro
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
grant = await fresh_grant(phone, "revoke_all_sessions", "all")
response = await phone.delete(
"/api/v1/sessions",
headers={
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
phone_private = await phone.get("/api/v1/background-identity")
@@ -452,18 +595,26 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
grant = await fresh_grant(client, "revoke_all_sessions", "all")
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def consume_step_up(self, grant, session_id, *, action, target):
return True
def revoke_all(self):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/sessions",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
headers={
"Origin": "https://test",
"X-CSRF-Token": csrf,
"X-Step-Up-Grant": grant,
},
)
assert response.status_code == 503
+38 -3
View File
@@ -14,7 +14,7 @@ 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: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200, responsePayload: {{}} }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -26,8 +26,9 @@ const boundary = createSessionBoundary({{
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(JSON.stringify(state.responsePayload), {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }});
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
sessionStorage: storage,
@@ -53,6 +54,7 @@ const boundary = createSessionBoundary({{
}},
onClearError: error => state.clearErrors.push(error.message),
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
@@ -71,6 +73,39 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario(
"""
state.responses = [
{status:428, payload:{detail:{code:'step_up_required', action:'merge_pull', target:'stackchain/api#7'}}},
{status:201, payload:{grant:'one-time-grant', expires_in:90}},
{status:200, payload:{merged:true}},
];
const original = { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({expected_head_sha:'abc123'}) };
const response = await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/pulls/7/merge', original);
state.finalStatus = response.status;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["finalStatus"] == 200
assert result["prompts"] == [
{"action": "merge_pull", "target": "stackchain/api#7"}
]
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
"/dashboard/api/v1/fresh-authorization",
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
]
assert json.loads(result["requests"][1]["body"]) == {
"access_token": "correct horse battery staple",
"action": "merge_pull",
"target": "stackchain/api#7",
}
assert result["requests"][2]["headers"]["x-step-up-grant"] == "one-time-grant"
assert result["requests"][2]["body"] == result["requests"][0]["body"]
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
result = run_session_scenario(
"""
+89
View File
@@ -119,3 +119,92 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
devices = store.list_active("existing-session")
assert len(devices) == 2
assert next(device for device in devices if device.current).device_label == "Existing device"
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
)
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
store = SessionStore(database, clock=lambda: 1_000.0)
grant = store.mint_step_up(
"existing-session", action="revoke_all_sessions", target="all", ttl_seconds=90
)
assert store.consume_step_up(
grant,
"existing-session",
action="revoke_all_sessions",
target="all",
) is True
def test_step_up_grant_is_single_use_and_bound_to_session_action_and_target(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 2_000)
grant = store.mint_step_up(
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
)
assert store.consume_step_up(
grant, "phone-session", action="merge_pull", target="stackchain/api#8"
) is False
assert store.consume_step_up(
grant, "other-session", action="merge_pull", target="stackchain/api#7"
) is False
assert store.consume_step_up(
grant, "phone-session", action="close_issue", target="stackchain/api#7"
) is False
assert store.consume_step_up(
grant, "phone-session", action="merge_pull", target="stackchain/api#7"
) is True
assert store.consume_step_up(
grant, "phone-session", action="merge_pull", target="stackchain/api#7"
) is False
assert grant.encode() not in store.path.read_bytes()
def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 2_000)
expired = store.mint_step_up(
"phone-session", action="close_issue", target="stackchain/api#7", ttl_seconds=90
)
revoked = store.mint_step_up(
"phone-session", action="revoke_all_sessions", target="all", ttl_seconds=90
)
now[0] = 1_091.0
assert store.consume_step_up(
expired, "phone-session", action="close_issue", target="stackchain/api#7"
) is False
now[0] = 1_010.0
store.revoke("phone-session")
assert store.consume_step_up(
revoked, "phone-session", action="revoke_all_sessions", target="all"
) is False
def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session", 2_000, device_label="Phone")
grant = store.mint_step_up(
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
)
phone = store.list_active("phone-session")[0]
assert store.revoke_managed(phone.management_id) is True
assert store.consume_step_up(
grant,
"phone-session",
action="merge_pull",
target="stackchain/api#7",
) is False