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

+18 -3
View File
@@ -22,6 +22,7 @@
const button = options.button;
const fetchImpl = options.fetchImpl;
const location = options.location;
const clearPrivateDeviceData = options.clearPrivateDeviceData;
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
@@ -29,9 +30,22 @@
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
function showReason(reason) {
if (reason !== 'session-expired') return;
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
async function showReason(reason) {
if (reason === 'session-expired') {
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
try {
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
await clearPrivateDeviceData();
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
button.disabled = false;
} catch (_error) {
status.textContent = '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.';
}
}
function showRetryCountdown(seconds) {
@@ -92,6 +106,7 @@ if (typeof document !== 'undefined') {
fetchImpl: fetch.bind(window),
location: window.location,
continuation: loginParams.get('continue'),
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
});
controller.showReason(loginParams.get('reason'));
form.addEventListener('submit', event => {
+61
View File
@@ -0,0 +1,61 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.stackchainPrivateDeviceData = factory({
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
});
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
}) {
function removeOwnedStorage(storage) {
if (!storage) return;
const keys = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith('stackchain.')) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
return async function clearPrivateDeviceData() {
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateOutbox();
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
};
});
+15 -1
View File
@@ -66,6 +66,16 @@ async function withSessionCsrf(work) {
}
}
async function purgeRevokedSessionData() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
@@ -89,9 +99,13 @@ async function fetchJson(url, options = {}) {
const response = await fetch(new URL(url, self.location.origin), requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const code = payload.code || payload.detail?.code;
if (response.status === 401 && code === 'session_revoked') {
await purgeRevokedSessionData();
}
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
error.code = payload.detail?.code;
error.code = code;
throw error;
}
return payload;
+23 -2
View File
@@ -80,6 +80,7 @@
});
boundary.resumeQueuedWork();
};
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach();
root.stackchainSession = boundary;
@@ -92,6 +93,19 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
async function handleRemoteRevocation() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
location.replace(base + 'login?reason=session-revoked');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
}
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
.map(value => value.trim())
@@ -116,8 +130,14 @@
const response = await fetchImpl(input, requestOptions);
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
expirationStarted = true;
onExpired();
location.replace(base + 'login?reason=session-expired');
const payload = await response.clone().json().catch(() => ({}));
if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else {
onExpired();
location.replace(base + 'login?reason=session-expired');
}
}
return response;
}
@@ -230,6 +250,7 @@
listActiveDevices,
revokeActiveDevice,
clearPrivateDeviceData,
handleServiceWorkerMessage,
resumeQueuedWork,
};
});