From 5c4092c1cb75c2a85ad7177ed4aff3afbb3e7b4e Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Mon, 9 Feb 2026 14:17:14 +0000 Subject: [PATCH] Guard against parsing non-JSON responses as JSON Check Content-Type header in ErrorBoundary before attempting .json() on error Response objects, preventing SyntaxError when server returns HTML (e.g. 500 pages, login redirects). Also add response.ok guards to unprotected fetch calls in return.js, CropFinishedStep, and patch-theme. Closes #8481 Co-Authored-By: Claude Opus 4.6 --- app/javascript/bootcamp/return.js | 6 ++++ app/javascript/components/ErrorBoundary.tsx | 32 +++++++++++-------- .../cropping-modal/CropFinishedStep.tsx | 1 + .../hooks/use-theme-observer/patch-theme.ts | 5 ++- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/javascript/bootcamp/return.js b/app/javascript/bootcamp/return.js index bf0aa93429..2f9d5ae5b9 100644 --- a/app/javascript/bootcamp/return.js +++ b/app/javascript/bootcamp/return.js @@ -19,6 +19,12 @@ async function initialize() { const response = await fetch( `/courses/stripe/session-status?session_id=${sessionId}&enrollment_uuid=${enrollmentUuid}` ) + + if (!response.ok) { + window.location.replace(failurePath) + return + } + const session = await response.json() if (session.status == 'open') { diff --git a/app/javascript/components/ErrorBoundary.tsx b/app/javascript/components/ErrorBoundary.tsx index d4196665c9..ba836e0cab 100644 --- a/app/javascript/components/ErrorBoundary.tsx +++ b/app/javascript/components/ErrorBoundary.tsx @@ -94,19 +94,25 @@ export const useErrorHandler = ( handler(new HandledError(defaultError.message)) } else if (error instanceof Response) { - error - .clone() - .json() - .then((res: { error: APIError }) => { - handler(new HandledError(res.error.message)) - }) - .catch((e) => { - if (process.env.NODE_ENV == 'production') { - Sentry.captureException(e) - } - - handler(new HandledError(defaultError.message)) - }) + const contentType = error.headers.get('Content-Type') + const isJson = + contentType && + (contentType.includes('application/json') || + contentType.includes('+json')) + + if (isJson) { + error + .clone() + .json() + .then((res: { error: APIError }) => { + handler(new HandledError(res.error.message)) + }) + .catch(() => { + handler(new HandledError(defaultError.message)) + }) + } else { + handler(new HandledError(defaultError.message)) + } } }, [defaultError, error, handler]) } diff --git a/app/javascript/components/profile/avatar-selector/cropping-modal/CropFinishedStep.tsx b/app/javascript/components/profile/avatar-selector/cropping-modal/CropFinishedStep.tsx index 2412c1cdab..1563d170c9 100644 --- a/app/javascript/components/profile/avatar-selector/cropping-modal/CropFinishedStep.tsx +++ b/app/javascript/components/profile/avatar-selector/cropping-modal/CropFinishedStep.tsx @@ -49,6 +49,7 @@ export const CropFinishedStep = ({ /* TODO: (optional) Use our standard sendRequest library */ return fetch(links.update, { body: formData, method: 'PATCH' }) .then((response) => { + if (!response.ok) throw response return response.json().then((json) => camelizeKeys(json)) }) .then((json) => { diff --git a/app/javascript/hooks/use-theme-observer/patch-theme.ts b/app/javascript/hooks/use-theme-observer/patch-theme.ts index 1a63c00349..1a67792ba4 100644 --- a/app/javascript/hooks/use-theme-observer/patch-theme.ts +++ b/app/javascript/hooks/use-theme-observer/patch-theme.ts @@ -11,7 +11,10 @@ function patchTheme(theme: string, updateEndpoint?: string) { user_preferences: { theme }, }), }) - .then((res) => res.json()) + .then((res) => { + if (!res.ok) throw new Error('Failed to update theme') + return res.json() + }) .catch((e) => // eslint-disable-next-line no-console console.error('Failed to update to accessibility-dark theme: ', e)