Skip to content

Commit 495f1cf

Browse files
committed
Restrict page→content bridge to set-speed only
The message bridge now accepts exactly one page→content verb: `set-speed` with a validated, clamped numeric value. All persistent settings writes go through trusted extension contexts (options page, popup, background) which have direct chrome.storage access. StorageManager.set() in page context routes only lastSpeed through the bridge; other keys stay in-memory only. https://claude.ai/code/session_01PJ13vEo3Jq8Qui4Tbxaxac
1 parent c66fc3d commit 495f1cf

3 files changed

Lines changed: 167 additions & 40 deletions

File tree

src/content/injection-bridge.js

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,21 @@ export async function injectScript(scriptPath) {
2424
});
2525
}
2626

27+
/**
28+
* Speed limits for page→content bridge validation.
29+
* Duplicated from constants.js because the content script (isolated world)
30+
* cannot import page-context modules.
31+
*/
32+
const SPEED_MIN = 0.07;
33+
const SPEED_MAX = 16;
34+
2735
/**
2836
* Set up message bridge between content script and page context.
29-
* Handles bi-directional communication for popup and settings updates.
37+
*
38+
* Page → Content accepts only `set-speed` (validated, clamped number).
39+
* Content → Page provides storage-changed broadcasts and VSC_MESSAGE commands.
40+
* All persistent settings writes go through trusted extension contexts.
41+
*
3042
* @returns {{ sendCommand: (type: string, payload?: any) => void, cleanup: () => void }}
3143
*/
3244
export function setupMessageBridge() {
@@ -40,20 +52,16 @@ export function setupMessageBridge() {
4052

4153
if (source === 'vsc-page') {
4254
try {
43-
if (action === 'storage-update') {
44-
chrome.storage.sync.set(data);
45-
} else if (action === 'runtime-message') {
46-
if (data.type !== 'VSC_STATE_UPDATE') {
47-
chrome.runtime.sendMessage(data);
55+
if (action === 'set-speed') {
56+
// Validate and clamp to supported range.
57+
if (typeof data?.speed !== 'number' || !Number.isFinite(data.speed)) {
58+
console.warn('[VSC] Bridge: rejected set-speed — invalid speed value');
59+
return;
4860
}
49-
} else if (action === 'get-storage') {
50-
chrome.storage.sync.get(null, (items) => {
51-
window.postMessage({
52-
source: 'vsc-content',
53-
action: 'storage-data',
54-
data: items
55-
}, '*');
56-
});
61+
const clamped = Math.min(Math.max(data.speed, SPEED_MIN), SPEED_MAX);
62+
chrome.storage.sync.set({ lastSpeed: clamped });
63+
} else {
64+
console.warn(`[VSC] Bridge: unrecognized page action "${action}"`);
5765
}
5866
} catch (e) {
5967
if (e.message?.includes('Extension context invalidated')) {

src/core/storage-manager.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,29 @@ if (!window.VSC.StorageManager) {
8686
});
8787
});
8888
} else {
89-
// Page context - send save request to content script via message bridge
90-
window.VSC.logger.debug('Sending storage update to content script');
91-
92-
// Post message to content script
93-
window.postMessage({
94-
source: 'vsc-page',
95-
action: 'storage-update',
96-
data: data
97-
}, '*');
89+
// Page context — only speed updates are bridged to the content script.
90+
// All other settings writes go through extension contexts with direct
91+
// chrome.storage access (options page, popup, background).
92+
const keys = Object.keys(data);
93+
if (keys.length === 1 && keys[0] === 'lastSpeed') {
94+
const speed = data.lastSpeed;
95+
if (typeof speed === 'number' && Number.isFinite(speed)) {
96+
window.postMessage({
97+
source: 'vsc-page',
98+
action: 'set-speed',
99+
data: { speed }
100+
}, '*');
101+
} else {
102+
window.VSC.logger.warn('StorageManager.set: invalid lastSpeed value, ignoring');
103+
}
104+
} else {
105+
window.VSC.logger.warn(
106+
'StorageManager.set: non-speed writes are not allowed from page context — ' +
107+
'use a privileged extension context instead. Keys: ' + keys.join(', ')
108+
);
109+
}
98110

99-
// Update local settings cache
111+
// Update local settings cache regardless (keeps in-memory state current)
100112
window.VSC_settings = { ...window.VSC_settings, ...data };
101113

102114
return Promise.resolve();

tests/unit/content/injection-bridge.test.js

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Unit tests for injection-bridge.js
3-
* Focused on the context invalidation fix and core message forwarding
3+
* Validates bridge message handling, input validation, and context lifecycle
44
*/
55

66
import {
@@ -39,34 +39,105 @@ describe('InjectionBridge', () => {
3939
window.dispatchEvent(event);
4040
}
4141

42-
// --- Core forwarding ---
42+
// --- set-speed verb ---
4343

44-
it('storage-update forwards to chrome.storage.sync.set', () => {
44+
it('set-speed writes clamped lastSpeed to chrome.storage', () => {
4545
let setData = null;
4646
chrome.storage.sync.set = (data) => {
4747
setData = data;
4848
};
4949

5050
bridge = setupMessageBridge();
51-
postPageMessage('storage-update', { lastSpeed: 2.5 });
51+
postPageMessage('set-speed', { speed: 2.5 });
5252

53-
expect(setData).toBeDefined();
54-
expect(setData.lastSpeed).toBe(2.5);
53+
expect(setData).toEqual({ lastSpeed: 2.5 });
5554
});
5655

57-
it('runtime-message filters out VSC_STATE_UPDATE', () => {
58-
let sendCalled = false;
59-
chrome.runtime.sendMessage = () => {
60-
sendCalled = true;
56+
it('set-speed clamps speed to minimum (0.07)', () => {
57+
let setData = null;
58+
chrome.storage.sync.set = (data) => {
59+
setData = data;
60+
};
61+
62+
bridge = setupMessageBridge();
63+
postPageMessage('set-speed', { speed: 0.01 });
64+
65+
expect(setData).toEqual({ lastSpeed: 0.07 });
66+
});
67+
68+
it('set-speed clamps speed to maximum (16)', () => {
69+
let setData = null;
70+
chrome.storage.sync.set = (data) => {
71+
setData = data;
72+
};
73+
74+
bridge = setupMessageBridge();
75+
postPageMessage('set-speed', { speed: 100 });
76+
77+
expect(setData).toEqual({ lastSpeed: 16 });
78+
});
79+
80+
it('set-speed requires a numeric value', () => {
81+
let setCalled = false;
82+
chrome.storage.sync.set = () => {
83+
setCalled = true;
84+
};
85+
86+
bridge = setupMessageBridge();
87+
postPageMessage('set-speed', { speed: 'fast' });
88+
89+
expect(setCalled).toBe(false);
90+
});
91+
92+
it('set-speed requires a finite value', () => {
93+
let setCalled = false;
94+
chrome.storage.sync.set = () => {
95+
setCalled = true;
96+
};
97+
98+
bridge = setupMessageBridge();
99+
postPageMessage('set-speed', { speed: NaN });
100+
expect(setCalled).toBe(false);
101+
102+
postPageMessage('set-speed', { speed: Infinity });
103+
expect(setCalled).toBe(false);
104+
});
105+
106+
it('set-speed requires data to be present', () => {
107+
let setCalled = false;
108+
chrome.storage.sync.set = () => {
109+
setCalled = true;
61110
};
62111

63112
bridge = setupMessageBridge();
64-
postPageMessage('runtime-message', { type: 'VSC_STATE_UPDATE' });
113+
postPageMessage('set-speed', null);
65114

115+
expect(setCalled).toBe(false);
116+
});
117+
118+
// --- Only set-speed is accepted from page context ---
119+
120+
it('only accepts the set-speed action', () => {
121+
let setCalled = false;
122+
let sendCalled = false;
123+
let getCalled = false;
124+
chrome.storage.sync.set = () => { setCalled = true; };
125+
chrome.storage.sync.get = () => { getCalled = true; };
126+
chrome.runtime.sendMessage = () => { sendCalled = true; };
127+
128+
bridge = setupMessageBridge();
129+
130+
postPageMessage('storage-update', { lastSpeed: 2.5 });
131+
postPageMessage('runtime-message', { type: 'EXTENSION_TOGGLE', enabled: false });
132+
postPageMessage('get-storage', {});
133+
postPageMessage('something-else', { payload: 'data' });
134+
135+
expect(setCalled).toBe(false);
66136
expect(sendCalled).toBe(false);
137+
expect(getCalled).toBe(false);
67138
});
68139

69-
// --- Context invalidation (the actual fix) ---
140+
// --- Context invalidation ---
70141

71142
it('Extension context invalidated removes the message listener', () => {
72143
chrome.storage.sync.set = () => {
@@ -76,15 +147,15 @@ describe('InjectionBridge', () => {
76147
bridge = setupMessageBridge();
77148

78149
// First message triggers invalidation — listener should self-remove
79-
postPageMessage('storage-update', { lastSpeed: 2.0 });
150+
postPageMessage('set-speed', { speed: 2.0 });
80151

81152
// Replace with a tracking mock — if listener was removed, this won't fire
82153
let calledAfter = false;
83154
chrome.storage.sync.set = () => {
84155
calledAfter = true;
85156
};
86157

87-
postPageMessage('storage-update', { lastSpeed: 3.0 });
158+
postPageMessage('set-speed', { speed: 3.0 });
88159

89160
expect(calledAfter).toBe(false);
90161
});
@@ -100,12 +171,48 @@ describe('InjectionBridge', () => {
100171

101172
bridge = setupMessageBridge();
102173

103-
postPageMessage('storage-update', { lastSpeed: 2.0 });
104-
postPageMessage('storage-update', { lastSpeed: 3.0 });
174+
postPageMessage('set-speed', { speed: 2.0 });
175+
postPageMessage('set-speed', { speed: 3.0 });
105176

106177
expect(callCount).toBe(2);
107178
});
108179

180+
// --- Message source filtering ---
181+
182+
it('requires vsc-page source prefix', () => {
183+
let setCalled = false;
184+
chrome.storage.sync.set = () => {
185+
setCalled = true;
186+
};
187+
188+
bridge = setupMessageBridge();
189+
190+
const event = new MessageEvent('message', {
191+
data: { source: 'other', action: 'set-speed', data: { speed: 2.0 } },
192+
});
193+
Object.defineProperty(event, 'source', { value: window });
194+
window.dispatchEvent(event);
195+
196+
expect(setCalled).toBe(false);
197+
});
198+
199+
it('only processes messages from vsc-page, not vsc-content', () => {
200+
let setCalled = false;
201+
chrome.storage.sync.set = () => {
202+
setCalled = true;
203+
};
204+
205+
bridge = setupMessageBridge();
206+
207+
const event = new MessageEvent('message', {
208+
data: { source: 'vsc-content', action: 'set-speed', data: { speed: 2.0 } },
209+
});
210+
Object.defineProperty(event, 'source', { value: window });
211+
window.dispatchEvent(event);
212+
213+
expect(setCalled).toBe(false);
214+
});
215+
109216
// --- sendCommand API ---
110217

111218
it('sendCommand dispatches VSC_MESSAGE CustomEvent to page context', () => {

0 commit comments

Comments
 (0)