From 01693215d6e3f598eef2aab520dc6c90e9d1bc2b Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Thu, 12 Feb 2026 17:18:06 +0000 Subject: [PATCH] Guard localStorage access against SecurityError in restricted browsers Browsers with strict privacy settings (Safari private browsing, restricted iframes, cookie-blocked contexts) throw a SecurityError when accessing window.localStorage. This adds a safeLocalStorage() helper that returns null when access is denied, wraps the four unprotected call sites, and adds a Sentry beforeSend filter to suppress the non-actionable error. Closes #8595 Co-Authored-By: Claude Opus 4.6 --- .../components/common/NewListItemForm.tsx | 10 +++++++--- .../mentoring/request/StartDiscussionPanel.tsx | 11 ++++++++--- app/javascript/packs/application.tsx | 18 +++++++++++------- app/javascript/utils/react-bootloader.tsx | 11 +++++++++++ app/javascript/utils/safe-local-storage.ts | 14 ++++++++++++++ 5 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 app/javascript/utils/safe-local-storage.ts diff --git a/app/javascript/components/common/NewListItemForm.tsx b/app/javascript/components/common/NewListItemForm.tsx index df11d9c5ec..264a6c2b32 100644 --- a/app/javascript/components/common/NewListItemForm.tsx +++ b/app/javascript/components/common/NewListItemForm.tsx @@ -21,9 +21,13 @@ export const NewListItemForm = ({ onCompressed?: () => void defaultError: Error }): JSX.Element => { - const [value, setValue] = useState( - localStorage.getItem(`smde_${contextId}`) || '' - ) + const [value, setValue] = useState(() => { + try { + return localStorage.getItem(`smde_${contextId}`) || '' + } catch { + return '' + } + }) const handleSuccess = useCallback( (item: T) => { setValue('') diff --git a/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx b/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx index 778ab91095..c0de0a354c 100644 --- a/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx +++ b/app/javascript/components/mentoring/request/StartDiscussionPanel.tsx @@ -29,9 +29,14 @@ export const StartDiscussionPanel = ({ links: Links }): JSX.Element => { const contextId = `start-discussion-request-${request.uuid}` - const [state, setState] = useState({ - expanded: defaultExpanded, - value: localStorage.getItem(`smde_${contextId}`) || '', + const [state, setState] = useState(() => { + let value = '' + try { + value = localStorage.getItem(`smde_${contextId}`) || '' + } catch { + // localStorage may be inaccessible (e.g. Safari private browsing) + } + return { expanded: defaultExpanded, value } }) const lastIteration = iterations[iterations.length - 1] diff --git a/app/javascript/packs/application.tsx b/app/javascript/packs/application.tsx index a0c9373430..12d8b55ded 100644 --- a/app/javascript/packs/application.tsx +++ b/app/javascript/packs/application.tsx @@ -4,6 +4,7 @@ import { lazy } from '@/utils/lazy-with-retry' import { camelizeKeys } from 'humps' import { camelizeKeysAs } from '@/utils/camelize-keys-as' import { initReact } from '@/utils/react-bootloader' +import { safeLocalStorage } from '@/utils/safe-local-storage' import { RenderLoader } from '@/components/common/RenderLoader' import 'focus-visible' import 'tippy.js/animations/shift-away-subtle.css' @@ -220,7 +221,7 @@ declare global { if (typeof window !== 'undefined') { const persister = createSyncStoragePersister({ - storage: window.localStorage, + storage: safeLocalStorage(), key: 'REACT_QUERY_OFFLINE_CACHE', // Strip non-serializable `promise` fields from dehydrated query state. // When a query is pending during dehydration, its state includes a real @@ -873,13 +874,16 @@ document.addEventListener('submit', function (event: SubmitEvent) { 'frontend-training-page-size', ] - const allKeys = Object.keys(localStorage) + const storage = safeLocalStorage() + if (storage) { + const allKeys = Object.keys(storage) - allKeys.forEach((key) => { - if (!keysToKeep.includes(key)) { - localStorage.removeItem(key) - } - }) + allKeys.forEach((key) => { + if (!keysToKeep.includes(key)) { + storage.removeItem(key) + } + }) + } } }) diff --git a/app/javascript/utils/react-bootloader.tsx b/app/javascript/utils/react-bootloader.tsx index 661f21ecd6..b14fce06e5 100644 --- a/app/javascript/utils/react-bootloader.tsx +++ b/app/javascript/utils/react-bootloader.tsx @@ -128,6 +128,17 @@ if (process.env.SENTRY_DSN) { ) if (isStudentCodeError) return null + // Drop non-actionable localStorage/sessionStorage SecurityErrors (Safari private + // browsing, restricted iframes, or strict cookie settings block storage access entirely) + const isStorageAccessError = event.exception?.values?.some( + (ex) => + ex.value?.includes( + "read the 'localStorage' property from 'Window'" + ) || + ex.value?.includes("read the 'sessionStorage' property from 'Window'") + ) + if (isStorageAccessError) return null + const tag = document.querySelector( 'meta[name="user-id"]' ) diff --git a/app/javascript/utils/safe-local-storage.ts b/app/javascript/utils/safe-local-storage.ts new file mode 100644 index 0000000000..953b3347f9 --- /dev/null +++ b/app/javascript/utils/safe-local-storage.ts @@ -0,0 +1,14 @@ +/** + * Safely access window.localStorage. + * + * In some browsers (Safari private browsing, restricted iframes, strict cookie + * settings) even reading window.localStorage throws a SecurityError. This + * helper returns null when access is denied so callers can degrade gracefully. + */ +export function safeLocalStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } +}