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

+37 -1
View File
@@ -16,6 +16,9 @@
MessageChannel: root.MessageChannel,
location: root.location,
confirmAction: message => root.confirm(message),
promptAuthorization: details => root.prompt(
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
),
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
onClearError: error => {
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
@@ -87,6 +90,7 @@
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
}) {
@@ -118,7 +122,7 @@
catch (_error) { return false; }
}
async function sessionFetch(input, options = {}) {
async function sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
@@ -128,6 +132,38 @@
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
const payload = await response.clone().json().catch(() => ({}));
const detail = payload?.detail || {};
if (detail.code === 'step_up_required' && detail.action && detail.target) {
const accessToken = await promptAuthorization({
action: detail.action,
target: detail.target,
});
if (!accessToken) return response;
const authorizationHeaders = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
action: detail.action,
target: detail.target,
}),
});
if (!authorization.ok) return authorization;
const grant = await authorization.json().catch(() => ({}));
if (!grant.grant) return response;
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
retryHeaders.set('X-Step-Up-Grant', grant.grant);
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
}
}
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
expirationStarted = true;
const payload = await response.clone().json().catch(() => ({}));