fix: validate X-Refreshed-Token before storing it as the auth token (#971)

* fix: validate X-Refreshed-Token before storing it as the auth token

authenticatedFetch stored any X-Refreshed-Token response header value
straight into localStorage. A malformed or injected header value would
silently overwrite the stored auth token, breaking every subsequent
authenticated request. Only accept values that match the JWT shape the
server actually issues (three base64url segments).

* fix: apply refreshed-token validation to file upload path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYpHw7VYW1d2HwfL9EZKx4

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LeChristopher Blackwell
2026-07-08 05:38:23 -07:00
committed by GitHub
parent 123d244a51
commit 5884573a69
2 changed files with 14 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ import type { DragEvent } from 'react';
import { IS_PLATFORM } from '../../../constants/config'; import { IS_PLATFORM } from '../../../constants/config';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { isValidRefreshedToken } from '../../../utils/api';
import { import {
MAX_FILE_UPLOAD_COUNT, MAX_FILE_UPLOAD_COUNT,
MAX_FILE_UPLOAD_SIZE_BYTES, MAX_FILE_UPLOAD_SIZE_BYTES,
@@ -131,7 +132,7 @@ const uploadFormDataWithProgress = (
xhr.onload = () => { xhr.onload = () => {
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token'); const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
if (refreshedToken) { if (isValidRefreshedToken(refreshedToken)) {
localStorage.setItem('auth-token', refreshedToken); localStorage.setItem('auth-token', refreshedToken);
} }

View File

@@ -1,5 +1,16 @@
import { IS_PLATFORM } from "../constants/config"; import { IS_PLATFORM } from "../constants/config";
// Only accept a refreshed token that has this app's issued JWT shape
// (three base64url segments). An attacker-injected/malformed header value
// must never overwrite the stored auth token.
/**
* @param {unknown} token
* @returns {token is string}
*/
export const isValidRefreshedToken = (token) =>
typeof token === 'string' &&
/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
// Utility function for authenticated API calls // Utility function for authenticated API calls
export const authenticatedFetch = (url, options = {}) => { export const authenticatedFetch = (url, options = {}) => {
const token = localStorage.getItem('auth-token'); const token = localStorage.getItem('auth-token');
@@ -23,7 +34,7 @@ export const authenticatedFetch = (url, options = {}) => {
}, },
}).then((response) => { }).then((response) => {
const refreshedToken = response.headers.get('X-Refreshed-Token'); const refreshedToken = response.headers.get('X-Refreshed-Token');
if (refreshedToken) { if (isValidRefreshedToken(refreshedToken)) {
localStorage.setItem('auth-token', refreshedToken); localStorage.setItem('auth-token', refreshedToken);
} }
return response; return response;