openoperator / webui /js /api.js
Leon4gr45's picture
fix: CSRF token retry re-read stale <meta> tag instead of fetching a live token, causing permanent CSRF failures after any server restart
1cc8ee6 verified
Raw
History Blame Contribute Delete
3.44 kB
/**
* Call a JSON-in JSON-out API endpoint
* Data is automatically serialized
* @param {string} endpoint - The API endpoint to call
* @param {any} data - The data to send to the API
* @returns {Promise<any>} The JSON response from the API
*/
export async function callJsonApi(endpoint, data) {
const response = await fetchApi(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
const jsonResponse = await response.json();
return jsonResponse;
}
/**
* Fetch wrapper for A0 APIs that ensures token exchange
* Automatically adds CSRF token to request headers
* @param {string} url - The URL to fetch
* @param {Object} [request] - The fetch request options
* @returns {Promise<Response>} The fetch response
*/
export async function fetchApi(url, request) {
async function _wrap(canRetry, forceFreshToken) {
// get the CSRF token
const token = await getCsrfToken(forceFreshToken);
// create a new request object if none was provided
const finalRequest = request || {};
// ensure headers object exists
finalRequest.headers = finalRequest.headers || {};
// add the CSRF token to the headers
finalRequest.headers["X-CSRF-Token"] = token;
// perform the fetch with the updated request
const response = await fetch(url, finalRequest);
// check if there was an CSRF error
if (response.status === 403 && canRetry) {
// retry the request with a freshly-fetched token. forceFreshToken=true is required here:
// getCsrfToken() would otherwise re-read the <meta> tag, which is baked into the page at
// load time and never updates -- so a stale meta-tag token (e.g. after the server
// restarted and rotated its CSRF secret) would fail the exact same way forever, since
// this "retry" kept re-reading the identical stale value instead of ever hitting
// /csrf_token for a token signed by the server's current secret.
csrfToken = null;
return await _wrap(false, true);
}
// return the response
return response;
}
// perform the request
const response = await _wrap(true, false);
// return the response
return response;
}
// csrf token stored locally
let csrfToken = null;
/**
* Get the CSRF token for API requests
* Caches the token after first request
* @param {boolean} [forceRefresh] - Skip the cache and the page's <meta> tag, always fetch a
* live token from /csrf_token. Use this when retrying after a 403.
* @returns {Promise<string>} The CSRF token
*/
async function getCsrfToken(forceRefresh = false) {
if (!forceRefresh && csrfToken) return csrfToken;
if (!forceRefresh) {
const tokenElement = document.querySelector('meta[name="csrf-token"]');
if (tokenElement) {
csrfToken = tokenElement.content;
return csrfToken;
}
}
// live fetch: reached on first load with no <meta> tag, or whenever a fresh token was
// explicitly requested (forceRefresh) after the cached/meta-tag token was rejected
const response = await fetch("/csrf_token", {
credentials: "same-origin",
}).then((r) => r.json());
csrfToken = response.token;
document.cookie = `csrf_token_${response.runtime_id}=${csrfToken}; SameSite=Strict; Path=/`;
return csrfToken;
}