From b6183011069d401cc600595cd47e078529744606 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 14:49:13 +0000 Subject: [PATCH] Fix localStorage access throwing in restricted environments (#524) Replace all direct localStorage calls with safeLocalStorage wrapper that detects availability and falls back to in-memory storage. This prevents crashes in mobile webviews and iOS Safari private browsing. https://claude.ai/code/session_01GW85dHHX1Jv15PEoMqdiah --- src/authorization/authorization.ts | 47 ++++++++------- .../unknownUserEventManager.ts | 57 ++++++++++-------- src/utils/authorizationToken.ts | 7 ++- src/utils/functions.ts | 7 ++- src/utils/localStorage.test.ts | 46 ++++++++++++++ src/utils/localStorage.ts | 60 +++++++++++++++++++ 6 files changed, 170 insertions(+), 54 deletions(-) create mode 100644 src/utils/localStorage.test.ts create mode 100644 src/utils/localStorage.ts diff --git a/src/authorization/authorization.ts b/src/authorization/authorization.ts index 24a6bdb7..45adc708 100644 --- a/src/authorization/authorization.ts +++ b/src/authorization/authorization.ts @@ -23,6 +23,7 @@ import { import { IdentityResolution, Options, config } from '../utils/config'; import { getTypeOfAuth, setTypeOfAuth, TypeOfAuth } from '../utils/typeOfAuth'; import AuthorizationToken from '../utils/authorizationToken'; +import { safeLocalStorage } from '../utils/localStorage'; import { cancelAxiosRequestAndMakeFetch, getEpochDifferenceInMS, @@ -93,7 +94,7 @@ const doesRequestUrlContain = (routeConfig: RouteConfig) => const addUserIdToRequest = (userId: string) => { setTypeOfAuth('userID'); authIdentifier = userId; - localStorage.setItem(SHARED_PREF_USER_ID, userId); + safeLocalStorage.setItem(SHARED_PREF_USER_ID, userId); if (typeof userInterceptor === 'number') { baseAxiosRequest.interceptors.request.eject(userInterceptor); @@ -217,18 +218,18 @@ export const setUnknownUserId = async (userId: string) => { } addUserIdToRequest(userId); - localStorage.setItem(SHARED_PREF_UNKNOWN_USER_ID, userId); + safeLocalStorage.setItem(SHARED_PREF_UNKNOWN_USER_ID, userId); }; registerUnknownUserIdSetter(setUnknownUserId); const clearUnknownUser = () => { - localStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); + safeLocalStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); }; const getUnknownUserId = () => { if (config.getConfig('enableUnknownActivation')) { - const unknownUser = localStorage.getItem(SHARED_PREF_UNKNOWN_USER_ID); + const unknownUser = safeLocalStorage.getItem(SHARED_PREF_UNKNOWN_USER_ID); return unknownUser === undefined ? null : unknownUser; } return null; @@ -242,7 +243,7 @@ const initializeUserId = (userId: string) => { const addEmailToRequest = (email: string) => { setTypeOfAuth('email'); authIdentifier = email; - localStorage.setItem(SHARED_PREF_EMAIL, email); + safeLocalStorage.setItem(SHARED_PREF_EMAIL, email); if (typeof userInterceptor === 'number') { baseAxiosRequest.interceptors.request.eject(userInterceptor); @@ -621,9 +622,9 @@ export function initialize( unknownUserManager.removeUnknownSessionCriteriaData(); setTypeOfAuth(null); authIdentifier = null; - localStorage.removeItem(SHARED_PREF_EMAIL); - localStorage.removeItem(SHARED_PREF_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeLocalStorage.removeItem(SHARED_PREF_EMAIL); + safeLocalStorage.removeItem(SHARED_PREF_USER_ID); + safeLocalStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); /* clear fetched in-app messages */ clearMessages(); @@ -652,14 +653,14 @@ export function initialize( /* if consent is true, we want to clear unknown user data and start tracking */ if (consent) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); + safeLocalStorage.removeItem(SHARED_PREFS_CRITERIA); // Store consent timestamp when user grants consent - const existingConsent = localStorage.getItem( + const existingConsent = safeLocalStorage.getItem( SHARED_PREF_CONSENT_TIMESTAMP ); if (!existingConsent) { - localStorage.setItem( + safeLocalStorage.setItem( SHARED_PREF_CONSENT_TIMESTAMP, Date.now().toString() ); @@ -671,9 +672,9 @@ export function initialize( if (unknownUsageTracked) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); - localStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeLocalStorage.removeItem(SHARED_PREFS_CRITERIA); + safeLocalStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); + safeLocalStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); setTypeOfAuth(null); authIdentifier = null; @@ -1066,9 +1067,9 @@ export function initialize( unknownUserManager.removeUnknownSessionCriteriaData(); setTypeOfAuth(null); authIdentifier = null; - localStorage.removeItem(SHARED_PREF_EMAIL); - localStorage.removeItem(SHARED_PREF_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeLocalStorage.removeItem(SHARED_PREF_EMAIL); + safeLocalStorage.removeItem(SHARED_PREF_USER_ID); + safeLocalStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); /* clear fetched in-app messages */ clearMessages(); @@ -1116,14 +1117,14 @@ export function initialize( /* if consent is true, we want to clear unknown user data and start tracking */ if (consent) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); + safeLocalStorage.removeItem(SHARED_PREFS_CRITERIA); // Store consent timestamp when user grants consent - const existingConsent = localStorage.getItem( + const existingConsent = safeLocalStorage.getItem( SHARED_PREF_CONSENT_TIMESTAMP ); if (!existingConsent) { - localStorage.setItem( + safeLocalStorage.setItem( SHARED_PREF_CONSENT_TIMESTAMP, Date.now().toString() ); @@ -1135,9 +1136,9 @@ export function initialize( if (unknownUsageTracked) { unknownUserManager.removeUnknownSessionCriteriaData(); - localStorage.removeItem(SHARED_PREFS_CRITERIA); - localStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeLocalStorage.removeItem(SHARED_PREFS_CRITERIA); + safeLocalStorage.removeItem(SHARED_PREF_UNKNOWN_USER_ID); + safeLocalStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); setTypeOfAuth(null); authIdentifier = null; diff --git a/src/unknownUserTracking/unknownUserEventManager.ts b/src/unknownUserTracking/unknownUserEventManager.ts index 85c4f97b..94597c83 100644 --- a/src/unknownUserTracking/unknownUserEventManager.ts +++ b/src/unknownUserTracking/unknownUserEventManager.ts @@ -53,6 +53,7 @@ import { InAppTrackRequestParams } from '../events'; import { config } from '../utils/config'; import { consentRequestSchema } from './consent.schema'; +import { safeLocalStorage } from '../utils/localStorage'; // Type definitions for unknown event data objects type UnknownTrackEventData = { @@ -103,7 +104,9 @@ export function isUnknownUsageTracked(): boolean { if (!isEnabled) return false; // Also check if user has given consent (consent timestamp exists) - const consentTimestamp = localStorage.getItem(SHARED_PREF_CONSENT_TIMESTAMP); + const consentTimestamp = safeLocalStorage.getItem( + SHARED_PREF_CONSENT_TIMESTAMP + ); return consentTimestamp !== null; } @@ -114,7 +117,7 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - const strUnknownSessionInfo = localStorage.getItem( + const strUnknownSessionInfo = safeLocalStorage.getItem( SHARED_PREFS_UNKNOWN_SESSIONS ); let unknownSessionInfo: { @@ -145,7 +148,7 @@ export class UnknownUserEventManager { itbl_unknown_sessions: unknownSessionInfo.itbl_unknown_sessions }; - localStorage.setItem( + safeLocalStorage.setItem( SHARED_PREFS_UNKNOWN_SESSIONS, JSON.stringify(outputObject) ); @@ -168,7 +171,7 @@ export class UnknownUserEventManager { .then((response) => { const criteriaData: any = response.data; if (criteriaData) { - localStorage.setItem( + safeLocalStorage.setItem( SHARED_PREFS_CRITERIA, JSON.stringify(criteriaData) ); @@ -220,11 +223,11 @@ export class UnknownUserEventManager { } private checkCriteriaCompletion(): string | null { - const criteriaData = localStorage.getItem(SHARED_PREFS_CRITERIA); - const localStoredEventList = localStorage.getItem( + const criteriaData = safeLocalStorage.getItem(SHARED_PREFS_CRITERIA); + const localStoredEventList = safeLocalStorage.getItem( SHARED_PREFS_EVENT_LIST_KEY ); - const localStoredUserUpdate = localStorage.getItem( + const localStoredUserUpdate = safeLocalStorage.getItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY ); try { @@ -248,15 +251,15 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - let userData = localStorage.getItem(SHARED_PREFS_UNKNOWN_SESSIONS); + let userData = safeLocalStorage.getItem(SHARED_PREFS_UNKNOWN_SESSIONS); // If no session data exists, create it first if (!userData) { this.updateUnknownSession(); - userData = localStorage.getItem(SHARED_PREFS_UNKNOWN_SESSIONS); + userData = safeLocalStorage.getItem(SHARED_PREFS_UNKNOWN_SESSIONS); } - const strUserUpdate = localStorage.getItem( + const strUserUpdate = safeLocalStorage.getItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY ); const dataFields = strUserUpdate ? JSON.parse(strUserUpdate) : {}; @@ -317,12 +320,14 @@ export class UnknownUserEventManager { } async syncEvents() { - const strTrackEventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + const strTrackEventList = safeLocalStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); const trackEventList = strTrackEventList ? JSON.parse(strTrackEventList) : []; - const strUserUpdate = localStorage.getItem( + const strUserUpdate = safeLocalStorage.getItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY ); const userUpdateObject = strUserUpdate ? JSON.parse(strUserUpdate) : {}; @@ -387,7 +392,7 @@ export class UnknownUserEventManager { if (replayEnabled) { try { if (isUserKnown === true) { - const unknownUserCreated = localStorage.getItem( + const unknownUserCreated = safeLocalStorage.getItem( SHARED_PREF_UNKNOWN_USER_ID ); if (!unknownUserCreated) { @@ -407,9 +412,9 @@ export class UnknownUserEventManager { } removeUnknownSessionCriteriaData() { - localStorage.removeItem(SHARED_PREFS_UNKNOWN_SESSIONS); - localStorage.removeItem(SHARED_PREFS_EVENT_LIST_KEY); - localStorage.removeItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); + safeLocalStorage.removeItem(SHARED_PREFS_UNKNOWN_SESSIONS); + safeLocalStorage.removeItem(SHARED_PREFS_EVENT_LIST_KEY); + safeLocalStorage.removeItem(SHARED_PREFS_USER_UPDATE_OBJECT_KEY); } private async storeEventListToLocalStorage(newDataObject: UnknownEventData) { @@ -417,7 +422,9 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - const strTrackEventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + const strTrackEventList = safeLocalStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); let previousDataArray = []; if (strTrackEventList) { @@ -439,7 +446,7 @@ export class UnknownUserEventManager { ); } - localStorage.setItem( + safeLocalStorage.setItem( SHARED_PREFS_EVENT_LIST_KEY, JSON.stringify(previousDataArray) ); @@ -456,7 +463,7 @@ export class UnknownUserEventManager { if (!unknownUsageTracked) return; - const strUserUpdate = localStorage.getItem( + const strUserUpdate = safeLocalStorage.getItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY ); let userUpdateObject = {}; @@ -470,7 +477,7 @@ export class UnknownUserEventManager { ...newDataObject }; - localStorage.setItem( + safeLocalStorage.setItem( SHARED_PREFS_USER_UPDATE_OBJECT_KEY, JSON.stringify(userUpdateObject) ); @@ -547,7 +554,7 @@ export class UnknownUserEventManager { // Consent tracking methods getConsentTimestamp(): string | null { - return localStorage.getItem(SHARED_PREF_CONSENT_TIMESTAMP); + return safeLocalStorage.getItem(SHARED_PREF_CONSENT_TIMESTAMP); } hasConsent(): boolean { @@ -559,21 +566,21 @@ export class UnknownUserEventManager { // First priority: actual user credentials from login/signup if (typeOfAuth === 'email') { - const email = localStorage.getItem(SHARED_PREF_EMAIL); + const email = safeLocalStorage.getItem(SHARED_PREF_EMAIL); if (email) { return { email }; } } if (typeOfAuth === 'userID') { - const userId = localStorage.getItem(SHARED_PREF_USER_ID); + const userId = safeLocalStorage.getItem(SHARED_PREF_USER_ID); if (userId) { return { userId }; } } // Fallback: generated unknown user ID (for scenario 1: after /session call) - const unknownUserId = localStorage.getItem(SHARED_PREF_UNKNOWN_USER_ID); + const unknownUserId = safeLocalStorage.getItem(SHARED_PREF_UNKNOWN_USER_ID); if (unknownUserId) { return { userId: unknownUserId }; } @@ -608,7 +615,7 @@ export class UnknownUserEventManager { }); // Remove consent timestamp after successful call - localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + safeLocalStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); return response; } catch (error) { diff --git a/src/utils/authorizationToken.ts b/src/utils/authorizationToken.ts index 5b378f1b..f5aee52c 100644 --- a/src/utils/authorizationToken.ts +++ b/src/utils/authorizationToken.ts @@ -1,22 +1,23 @@ import { SHARED_PREF_USER_TOKEN } from '../constants'; +import { safeLocalStorage } from './localStorage'; class AuthorizationToken { public token: string | null = null; setToken(token: string) { this.token = token; - localStorage.setItem(SHARED_PREF_USER_TOKEN, token); + safeLocalStorage.setItem(SHARED_PREF_USER_TOKEN, token); } getToken(): string | null { return this.token && this.token.length > 0 ? this.token - : localStorage.getItem(SHARED_PREF_USER_TOKEN); + : safeLocalStorage.getItem(SHARED_PREF_USER_TOKEN); } clearToken() { this.token = null; - localStorage.removeItem(SHARED_PREF_USER_TOKEN); + safeLocalStorage.removeItem(SHARED_PREF_USER_TOKEN); } } diff --git a/src/utils/functions.ts b/src/utils/functions.ts index a5d81e70..13e7cdf1 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -1,4 +1,5 @@ import { SHARED_PREF_EMAIL, SHARED_PREF_USER_ID } from '../constants'; +import { safeLocalStorage } from './localStorage'; export default class { private static emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -9,11 +10,11 @@ export default class { public static addEmailOrUserIdToJson( jsonParams: any, - localStorage: Storage + storage: Pick = safeLocalStorage ): any { const store = jsonParams; - const userId = localStorage.getItem(SHARED_PREF_USER_ID); - const email = localStorage.getItem(SHARED_PREF_EMAIL); + const userId = storage.getItem(SHARED_PREF_USER_ID); + const email = storage.getItem(SHARED_PREF_EMAIL); if (userId) { store.userId = userId; } else if (email) { diff --git a/src/utils/localStorage.test.ts b/src/utils/localStorage.test.ts new file mode 100644 index 00000000..b0b93959 --- /dev/null +++ b/src/utils/localStorage.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable global-require */ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * @jest-environment jsdom + */ + +describe('safeLocalStorage', () => { + beforeEach(() => { + jest.resetModules(); + localStorage.clear(); + }); + + it('delegates to real localStorage when available', () => { + const { safeLocalStorage } = require('./localStorage'); + + safeLocalStorage.setItem('test-key', 'test-value'); + expect(localStorage.getItem('test-key')).toBe('test-value'); + + expect(safeLocalStorage.getItem('test-key')).toBe('test-value'); + + safeLocalStorage.removeItem('test-key'); + expect(localStorage.getItem('test-key')).toBeNull(); + }); + + it('falls back to in-memory storage when localStorage throws', () => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = jest.fn(() => { + throw new DOMException('QuotaExceededError'); + }); + + const { safeLocalStorage } = require('./localStorage'); + + Storage.prototype.setItem = originalSetItem; + + expect(() => safeLocalStorage.setItem('key', 'value')).not.toThrow(); + expect(safeLocalStorage.getItem('key')).toBe('value'); + + safeLocalStorage.removeItem('key'); + expect(safeLocalStorage.getItem('key')).toBeNull(); + }); + + it('returns null for missing keys', () => { + const { safeLocalStorage } = require('./localStorage'); + expect(safeLocalStorage.getItem('nonexistent')).toBeNull(); + }); +}); diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts new file mode 100644 index 00000000..94b78113 --- /dev/null +++ b/src/utils/localStorage.ts @@ -0,0 +1,60 @@ +/** + * Safe localStorage wrapper that gracefully handles environments where + * localStorage is unavailable (mobile webviews, iOS Safari private browsing). + * Falls back to an in-memory store for the duration of the session. + * + * See: https://github.com/Iterable/iterable-web-sdk/issues/524 + */ + +const createInMemoryStorage = (): Pick< + Storage, + 'getItem' | 'setItem' | 'removeItem' +> => { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + } + }; +}; + +const isLocalStorageAvailable = (): boolean => { + try { + const testKey = '__itbl_storage_test__'; + localStorage.setItem(testKey, 'test'); + localStorage.removeItem(testKey); + return true; + } catch { + return false; + } +}; + +const fallbackStorage = createInMemoryStorage(); +const localStorageAvailable = isLocalStorageAvailable(); + +/** + * Delegates to the real localStorage when available, falling back to + * in-memory storage otherwise. Uses delegation (not a static reference) + * so that test mocks on the global localStorage are respected. + */ +export const safeLocalStorage: Pick< + Storage, + 'getItem' | 'setItem' | 'removeItem' +> = { + getItem: (key: string): string | null => + localStorageAvailable + ? localStorage.getItem(key) + : fallbackStorage.getItem(key), + setItem: (key: string, value: string): void => + localStorageAvailable + ? localStorage.setItem(key, value) + : fallbackStorage.setItem(key, value), + removeItem: (key: string): void => + localStorageAvailable + ? localStorage.removeItem(key) + : fallbackStorage.removeItem(key) +};