security: purge data after remote session revocation (#337)
This commit is contained in:
1 parent
a9051d843c
commit
b4dc785dd8
13 files changed
+344
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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))});
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user