security: purge data after remote session revocation (#337)
CI / lint (pull_request) Successful in 37s
CI / build-frontend (pull_request) Successful in 6s

This commit is contained in:
timmy committed 2026-08-08 20:17:13 +00:00
1 parent a9051d843c
commit b4dc785dd8
13 files changed
+344 -20

No files matched your search

+7
View File
@@ -295,6 +295,7 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
},
)
phone_status = await phone.get("/api/v1/session")
phone_page = await phone.get("/", follow_redirects=False)
laptop_status = await laptop.get("/api/v1/session")
assert listed.status_code == 200
@@ -303,6 +304,12 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
assert missing_csrf.status_code == 403
assert revoked.json() == {"revoked": True, "current_session": False}
assert phone_status.status_code == 401
assert phone_status.json() == {
"detail": "Authentication required",
"code": "session_revoked",
}
assert phone_page.status_code == 303
assert phone_page.headers["location"] == "login?reason=session-revoked"
assert laptop_status.status_code == 200
assert "session_hash" not in listed.text
assert "csrf" not in listed.text
+33
View File
@@ -92,6 +92,39 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedCaches"] == []
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
assert result["replacedAfterDeletion"] is True
def test_worker_revocation_message_clears_window_storage_before_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
+69
View File
@@ -37,6 +37,75 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
assert state["ignored"] == state["expired"]
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{cleared:false, snapshots:[]}};
const status = {{textContent:''}};
const button = {{disabled:false}};
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status, button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
location:{{replace:()=>{{}}}},
clearPrivateDeviceData:async()=>{{
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await new Promise(resolve=>setTimeout(resolve,10));
state.cleared=true;
}},
}});
(async()=>{{
const pending=controller.showReason('session-revoked');
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await pending;
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
process.stdout.write(JSON.stringify(state));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
assert state["final"] == {
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
"disabled": False,
"cleared": True,
}
@pytest.mark.anyio
async def test_login_loads_private_data_purger_before_controller():
html = await login()
assert '<script src="static/private-device-data.js"></script>' in html
assert html.index('static/private-device-data.js') < html.index('static/login.js')
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status={{textContent:''}};
const button={{disabled:false}};
const controller=createLoginController({{
form:{{reset:()=>{{}}}},status,button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
}});
(async()=>{{
await controller.showReason('session-revoked');
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
}})();
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.",
"disabled": True,
}
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
+49
View File
@@ -0,0 +1,49 @@
import json
import subprocess
from pathlib import Path
PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-device-data.js"
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
harness = f"""
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
const storage = {{
values:new Map([['stackchain.draft','private'],['other.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 clear = createPrivateDeviceDataPurger({{
localStorage:storage, sessionStorage:storage,
indexedDB:{{deleteDatabase:name=>{{
state.databases.push(name);
const request={{}};
setTimeout(()=>{{state.complete=true;request.onsuccess?.();}},10);
return request;
}}}},
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
MessageChannel:class{{constructor(){{
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
this.port1=first;this.port2=second;
}}}},
}});
(async()=>{{
await clear();
process.stdout.write(JSON.stringify({{...state,remaining:Array.from(storage.values.keys())}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["remaining"] == ["other.preference"]
assert state["databases"] == ["stackchain-background-outbox-v1"]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["complete"] is True
+20
View File
@@ -146,6 +146,26 @@ def test_background_delivery_preserves_structured_uncertain_error():
}
def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_clients():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_revoked'}),{status:401,headers:{'Content-Type':'application/json'}});
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
process.stdout.write(JSON.stringify({state,outcome}));
"""
)
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-revoked"}
]
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""