Skip to content

Commit 2b4608e

Browse files
iHiDclaude
andauthored
Guard localStorage access against SecurityError in restricted browsers (#8615)
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 <noreply@anthropic.com>
1 parent 5426eb4 commit 2b4608e

5 files changed

Lines changed: 51 additions & 13 deletions

File tree

app/javascript/components/common/NewListItemForm.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ export const NewListItemForm = <T extends unknown>({
2121
onCompressed?: () => void
2222
defaultError: Error
2323
}): JSX.Element => {
24-
const [value, setValue] = useState(
25-
localStorage.getItem(`smde_${contextId}`) || ''
26-
)
24+
const [value, setValue] = useState(() => {
25+
try {
26+
return localStorage.getItem(`smde_${contextId}`) || ''
27+
} catch {
28+
return ''
29+
}
30+
})
2731
const handleSuccess = useCallback(
2832
(item: T) => {
2933
setValue('')

app/javascript/components/mentoring/request/StartDiscussionPanel.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@ export const StartDiscussionPanel = ({
2929
links: Links
3030
}): JSX.Element => {
3131
const contextId = `start-discussion-request-${request.uuid}`
32-
const [state, setState] = useState({
33-
expanded: defaultExpanded,
34-
value: localStorage.getItem(`smde_${contextId}`) || '',
32+
const [state, setState] = useState(() => {
33+
let value = ''
34+
try {
35+
value = localStorage.getItem(`smde_${contextId}`) || ''
36+
} catch {
37+
// localStorage may be inaccessible (e.g. Safari private browsing)
38+
}
39+
return { expanded: defaultExpanded, value }
3540
})
3641
const lastIteration = iterations[iterations.length - 1]
3742

app/javascript/packs/application.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { lazy } from '@/utils/lazy-with-retry'
44
import { camelizeKeys } from 'humps'
55
import { camelizeKeysAs } from '@/utils/camelize-keys-as'
66
import { initReact } from '@/utils/react-bootloader'
7+
import { safeLocalStorage } from '@/utils/safe-local-storage'
78
import { RenderLoader } from '@/components/common/RenderLoader'
89
import 'focus-visible'
910
import 'tippy.js/animations/shift-away-subtle.css'
@@ -220,7 +221,7 @@ declare global {
220221

221222
if (typeof window !== 'undefined') {
222223
const persister = createSyncStoragePersister({
223-
storage: window.localStorage,
224+
storage: safeLocalStorage(),
224225
key: 'REACT_QUERY_OFFLINE_CACHE',
225226
// Strip non-serializable `promise` fields from dehydrated query state.
226227
// When a query is pending during dehydration, its state includes a real
@@ -878,13 +879,16 @@ document.addEventListener('submit', function (event: SubmitEvent) {
878879
'frontend-training-page-size',
879880
]
880881

881-
const allKeys = Object.keys(localStorage)
882+
const storage = safeLocalStorage()
883+
if (storage) {
884+
const allKeys = Object.keys(storage)
882885

883-
allKeys.forEach((key) => {
884-
if (!keysToKeep.includes(key)) {
885-
localStorage.removeItem(key)
886-
}
887-
})
886+
allKeys.forEach((key) => {
887+
if (!keysToKeep.includes(key)) {
888+
storage.removeItem(key)
889+
}
890+
})
891+
}
888892
}
889893
})
890894

app/javascript/utils/react-bootloader.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ if (process.env.SENTRY_DSN) {
128128
)
129129
if (isStudentCodeError) return null
130130

131+
// Drop non-actionable localStorage/sessionStorage SecurityErrors (Safari private
132+
// browsing, restricted iframes, or strict cookie settings block storage access entirely)
133+
const isStorageAccessError = event.exception?.values?.some(
134+
(ex) =>
135+
ex.value?.includes(
136+
"read the 'localStorage' property from 'Window'"
137+
) ||
138+
ex.value?.includes("read the 'sessionStorage' property from 'Window'")
139+
)
140+
if (isStorageAccessError) return null
141+
131142
// Drop non-actionable "Maximum call stack size exceeded" errors.
132143
// These come from browser extensions, third-party scripts, or users
133144
// with cached bundles (the root cause in app code was fixed in #8408).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Safely access window.localStorage.
3+
*
4+
* In some browsers (Safari private browsing, restricted iframes, strict cookie
5+
* settings) even reading window.localStorage throws a SecurityError. This
6+
* helper returns null when access is denied so callers can degrade gracefully.
7+
*/
8+
export function safeLocalStorage(): Storage | null {
9+
try {
10+
return window.localStorage
11+
} catch {
12+
return null
13+
}
14+
}

0 commit comments

Comments
 (0)