Skip to content

Commit b3f618b

Browse files
Fix SecurityError when accessing localStorage in restricted browser environments (#1452)
* Initial plan * Wrap localStorage access in try-catch blocks to handle SecurityError * Add console.error logging for localStorage access failures Co-authored-by: matus-tomlein <[email protected]> * Fix test to use proper EventStorePayload format Co-authored-by: matus-tomlein <[email protected]> * Run rush change --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: matus-tomlein <[email protected]> Co-authored-by: Matus Tomlein <[email protected]>
1 parent e13f804 commit b3f618b

File tree

3 files changed

+204
-4
lines changed

3 files changed

+204
-4
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@snowplow/browser-tracker-core",
5+
"comment": "Fix SecurityError when accessing localStorage in restricted browser environments",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@snowplow/browser-tracker-core"
10+
}

libraries/browser-tracker-core/src/tracker/local_storage_event_store.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ export function newLocalStorageEventStore({
2121

2222
function newInMemoryEventStoreFromLocalStorage() {
2323
if (useLocalStorage) {
24-
const localStorageQueue = window.localStorage.getItem(queueName);
25-
const events: EventStorePayload[] = localStorageQueue ? JSON.parse(localStorageQueue) : [];
26-
return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize, events });
24+
try {
25+
const localStorageQueue = window.localStorage.getItem(queueName);
26+
const events: EventStorePayload[] = localStorageQueue ? JSON.parse(localStorageQueue) : [];
27+
return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize, events });
28+
} catch (e) {
29+
console.error('Failed to access localStorage when initializing event store:', e);
30+
return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize });
31+
}
2732
} else {
2833
return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize });
2934
}
@@ -34,7 +39,11 @@ export function newLocalStorageEventStore({
3439
function sync(): Promise<void> {
3540
if (useLocalStorage) {
3641
return getAll().then((events) => {
37-
window.localStorage.setItem(queueName, JSON.stringify(events));
42+
try {
43+
window.localStorage.setItem(queueName, JSON.stringify(events));
44+
} catch (e) {
45+
console.error('Failed to persist events to localStorage:', e);
46+
}
3847
});
3948
} else {
4049
return Promise.resolve();
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
* Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
import { newLocalStorageEventStore } from '../../src/tracker/local_storage_event_store';
32+
33+
describe('LocalStorageEventStore', () => {
34+
const trackerId = 'test-tracker';
35+
36+
beforeEach(() => {
37+
localStorage.clear();
38+
});
39+
40+
it('should create an event store with useLocalStorage enabled', async () => {
41+
const eventStore = newLocalStorageEventStore({
42+
trackerId,
43+
useLocalStorage: true,
44+
});
45+
46+
expect(await eventStore.count()).toBe(0);
47+
});
48+
49+
it('should create an event store with useLocalStorage disabled', async () => {
50+
const eventStore = newLocalStorageEventStore({
51+
trackerId,
52+
useLocalStorage: false,
53+
});
54+
55+
expect(await eventStore.count()).toBe(0);
56+
});
57+
58+
it('should add and retrieve events when localStorage is accessible', async () => {
59+
const eventStore = newLocalStorageEventStore({
60+
trackerId,
61+
useLocalStorage: true,
62+
});
63+
64+
const event = { payload: { e: 'pv', eid: 'test-event-id' } };
65+
await eventStore.add(event);
66+
67+
expect(await eventStore.count()).toBe(1);
68+
const events = await eventStore.getAllPayloads();
69+
expect(events[0]).toMatchObject(event.payload);
70+
});
71+
72+
it('should handle SecurityError when accessing localStorage.getItem', () => {
73+
const originalGetItem = Storage.prototype.getItem;
74+
Storage.prototype.getItem = jest.fn(() => {
75+
throw new DOMException('The operation is insecure.', 'SecurityError');
76+
});
77+
78+
// Should not throw an error, but should create an empty in-memory store
79+
const eventStore = newLocalStorageEventStore({
80+
trackerId,
81+
useLocalStorage: true,
82+
});
83+
84+
expect(eventStore).toBeDefined();
85+
expect(eventStore.count).toBeDefined();
86+
87+
Storage.prototype.getItem = originalGetItem;
88+
});
89+
90+
it('should handle SecurityError when accessing localStorage.setItem', async () => {
91+
const originalSetItem = Storage.prototype.setItem;
92+
Storage.prototype.setItem = jest.fn(() => {
93+
throw new DOMException('The operation is insecure.', 'SecurityError');
94+
});
95+
96+
const eventStore = newLocalStorageEventStore({
97+
trackerId,
98+
useLocalStorage: true,
99+
});
100+
101+
const event = { payload: { e: 'pv', eid: 'test-event-id' } };
102+
103+
// Should not throw an error, even though setItem fails
104+
await expect(eventStore.add(event)).resolves.toBeDefined();
105+
106+
// Event should still be in the in-memory store
107+
expect(await eventStore.count()).toBe(1);
108+
109+
Storage.prototype.setItem = originalSetItem;
110+
});
111+
112+
it('should gracefully handle errors when both getItem and setItem throw SecurityError', async () => {
113+
const originalGetItem = Storage.prototype.getItem;
114+
const originalSetItem = Storage.prototype.setItem;
115+
116+
Storage.prototype.getItem = jest.fn(() => {
117+
throw new DOMException('The operation is insecure.', 'SecurityError');
118+
});
119+
Storage.prototype.setItem = jest.fn(() => {
120+
throw new DOMException('The operation is insecure.', 'SecurityError');
121+
});
122+
123+
const eventStore = newLocalStorageEventStore({
124+
trackerId,
125+
useLocalStorage: true,
126+
});
127+
128+
const event = { payload: { e: 'pv', eid: 'test-event-id' } };
129+
await eventStore.add(event);
130+
131+
// Event should be in the in-memory store
132+
expect(await eventStore.count()).toBe(1);
133+
const events = await eventStore.getAllPayloads();
134+
expect(events[0]).toMatchObject(event.payload);
135+
136+
Storage.prototype.getItem = originalGetItem;
137+
Storage.prototype.setItem = originalSetItem;
138+
});
139+
140+
it('should persist events to localStorage when accessible', async () => {
141+
const eventStore = newLocalStorageEventStore({
142+
trackerId,
143+
useLocalStorage: true,
144+
});
145+
146+
const event = { payload: { e: 'pv', eid: 'test-event-id' } };
147+
await eventStore.add(event);
148+
149+
// Check that the event was persisted to localStorage
150+
const queueName = `snowplowOutQueue_${trackerId}`;
151+
const stored = localStorage.getItem(queueName);
152+
expect(stored).toBeDefined();
153+
expect(JSON.parse(stored!)).toHaveLength(1);
154+
});
155+
156+
it('should load events from localStorage on initialization', () => {
157+
const queueName = `snowplowOutQueue_${trackerId}`;
158+
const events = [{ payload: { e: 'pv', eid: 'event-1' } }, { payload: { e: 'pv', eid: 'event-2' } }];
159+
localStorage.setItem(queueName, JSON.stringify(events));
160+
161+
const eventStore = newLocalStorageEventStore({
162+
trackerId,
163+
useLocalStorage: true,
164+
});
165+
166+
expect(eventStore.count()).resolves.toBe(2);
167+
});
168+
169+
it('should not load from localStorage when useLocalStorage is false', () => {
170+
const queueName = `snowplowOutQueue_${trackerId}`;
171+
const events = [{ payload: { e: 'pv', eid: 'event-1' } }, { payload: { e: 'pv', eid: 'event-2' } }];
172+
localStorage.setItem(queueName, JSON.stringify(events));
173+
174+
const eventStore = newLocalStorageEventStore({
175+
trackerId,
176+
useLocalStorage: false,
177+
});
178+
179+
expect(eventStore.count()).resolves.toBe(0);
180+
});
181+
});

0 commit comments

Comments
 (0)