/** * Sender authorization for privileged extension messages. * * A content script runs in web-page context and can be influenced by page * content; a foreign extension is not us. Neither may read or spend the * browse server's auth token or port through background.js's message * surface. PR #1822 (@punksterlabs) found getPort handing the token to any * caller that passed the type allowlist; this suite pins the reimplemented * gate BEHAVIORALLY — it drives the real background.js onMessage listener * under a chrome stub with four sender shapes (own extension page, own * content script, foreign extension, url-less) and asserts denied responses * are { error: 'unauthorized' } with no token/port fields at all. */ import { describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; const EXT_DIR = path.join(import.meta.dir, '..', '..', 'extension'); const BG_SRC = fs.readFileSync(path.join(EXT_DIR, 'background.js'), 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-require-imports const senderAuth = require(path.join(EXT_DIR, 'sender-auth.js')); // The pinned production id (derivable via browse/scripts/extension-id.ts) — // the policy only compares it against sender.id, so any stable value works. const OWN_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep'; const FOREIGN_ID = 'ffffffffffffffffffffffffffffffff'; // ─── The four sender shapes ───────────────────────────────────── const PAGE_SENDER = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html` }; const CONTENT_SCRIPT_SENDER = { id: OWN_ID, url: 'https://evil.example/page', tab: { id: 42 } }; const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` }; const NO_URL_SENDER = { id: OWN_ID }; const PRIVILEGED = [ 'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs', 'command', 'sidebar-command', 'getTabState', ]; // Content-script-originated flows that must keep working. const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult']; // Sidepanel-originated, non-privileged (page effects only, no token/port). const PAGE_EFFECT_TYPES = ['sidebarOpened', 'startInspector', 'stopInspector', 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll']; const LEAK_FIELDS = ['token', 'authToken', 'port', 'url', 'connected', 'tabs', 'active', 'ok']; // ─── Unit: the policy predicate ───────────────────────────────── describe('sender-auth policy (unit)', () => { test('own extension page is allowed for every privileged type', () => { for (const type of PRIVILEGED) { expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull(); } expect(senderAuth.isExtensionPageSender(PAGE_SENDER, OWN_ID)).toBe(true); }); test('own popup page is allowed (any own-extension page path)', () => { const popup = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/popup.html` }; expect(senderAuth.denialFor('getPort', popup, OWN_ID)).toBeNull(); }); test('own content script (sender.tab + page URL) is denied for every privileged type', () => { for (const type of PRIVILEGED) { const denial = senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID); expect(denial).toEqual({ error: 'unauthorized' }); expect(Object.keys(denial)).toEqual(['error']); } }); test('foreign extension id is denied for every privileged type', () => { for (const type of PRIVILEGED) { expect(senderAuth.denialFor(type, FOREIGN_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' }); } }); test('missing sender.url is denied (no provenance)', () => { for (const type of PRIVILEGED) { expect(senderAuth.denialFor(type, NO_URL_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' }); } expect(senderAuth.denialFor('getToken', undefined, OWN_ID)).toEqual({ error: 'unauthorized' }); }); test('own extension page opened inside a TAB is denied (conservative: sender.tab wins)', () => { const pageInTab = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html`, tab: { id: 7 } }; expect(senderAuth.denialFor('getToken', pageInTab, OWN_ID)).toEqual({ error: 'unauthorized' }); }); test('non-privileged types are never gated here — content-script flows stay reachable', () => { for (const type of [...CONTENT_SCRIPT_TYPES, ...PAGE_EFFECT_TYPES]) { expect(senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID)).toBeNull(); expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull(); } }); }); // ─── Behavioral: the real background.js listener ──────────────── type Listener = (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void) => unknown; function loadBackground() { const captured: { listener?: Listener } = {}; const calls = { storageSet: [] as unknown[], fetch: [] as unknown[] }; const never = new Promise(() => {}); // storage.get never settles → startup health polling never starts const chromeStub = { runtime: { id: OWN_ID, onMessage: { addListener: (fn: Listener) => { captured.listener = fn; } }, onInstalled: { addListener: () => {} }, sendMessage: () => Promise.resolve(), }, storage: { local: { get: () => never, set: (obj: unknown) => { calls.storageSet.push(obj); return Promise.resolve(); }, }, }, tabs: { onActivated: { addListener: () => {} }, onCreated: { addListener: () => {} }, onRemoved: { addListener: () => {} }, onUpdated: { addListener: () => {} }, query: (_opts: unknown, cb?: (tabs: unknown[]) => void) => { if (cb) { cb([]); return; } return Promise.resolve([]); }, sendMessage: () => Promise.resolve(), get: () => {}, }, action: { setBadgeBackgroundColor: () => {}, setBadgeText: () => {} }, scripting: { executeScript: () => Promise.resolve(), insertCSS: () => Promise.resolve() }, // no chrome.sidePanel: autoOpenSidePanel exits immediately (no retry timers) }; const fetchSpy = (...args: unknown[]) => { calls.fetch.push(args); return Promise.reject(new Error('no network in tests')); }; // background.js is a classic (non-module) service worker script — evaluate // it with its globals injected. importScripts is satisfied by passing the // already-required sender-auth module under the global name it registers. const run = new Function('chrome', 'importScripts', 'gstackSenderAuth', 'fetch', BG_SRC); run(chromeStub, () => {}, senderAuth, fetchSpy); if (!captured.listener) throw new Error('background.js did not register an onMessage listener'); return { listener: captured.listener, calls }; } function dispatch(listener: Listener, msg: unknown, sender: unknown) { const result = { responded: false, response: undefined as Record | undefined }; listener(msg, sender, (resp: unknown) => { result.responded = true; result.response = resp as Record; }); return result; } // Denied senders get { error: 'unauthorized' } and nothing else — or no // response at all (the pre-existing foreign-sender early return). Either // way: never a token, port, or tab-state field. function expectDenied(result: ReturnType) { if (result.responded) { expect(result.response).toEqual({ error: 'unauthorized' }); expect(Object.keys(result.response!)).toEqual(['error']); } const resp = result.response ?? {}; for (const leak of LEAK_FIELDS) { expect(resp[leak]).toBeUndefined(); } } describe('background.js onMessage listener (behavioral)', () => { const { listener, calls } = loadBackground(); test('own sidepanel page: getPort responds with port/connected/token fields, no error', () => { const r = dispatch(listener, { type: 'getPort' }, PAGE_SENDER); expect(r.responded).toBe(true); expect('port' in r.response!).toBe(true); expect('connected' in r.response!).toBe(true); // The sidepanel's tryConnect reads resp.token — the field must exist for // extension pages (value is null until the token bootstrap completes). expect('token' in r.response!).toBe(true); expect(r.response!.error).toBeUndefined(); }); test('own sidepanel page: getToken responds with a token field', () => { const r = dispatch(listener, { type: 'getToken' }, PAGE_SENDER); expect(r.responded).toBe(true); expect('token' in r.response!).toBe(true); expect(r.response!.error).toBeUndefined(); }); test('own content script: every privileged type is denied with no token/port fields', () => { for (const type of PRIVILEGED) { const r = dispatch(listener, { type }, CONTENT_SCRIPT_SENDER); expect(r.responded).toBe(true); // the gate answers, it does not go silent expectDenied(r); } }); test('foreign extension: every privileged type yields no token/port fields', () => { for (const type of PRIVILEGED) { expectDenied(dispatch(listener, { type }, FOREIGN_SENDER)); } }); test('missing sender.url: every privileged type is denied', () => { for (const type of PRIVILEGED) { const r = dispatch(listener, { type }, NO_URL_SENDER); expect(r.responded).toBe(true); expectDenied(r); } }); test('denied setPort never persists the attacker port', () => { const before = calls.storageSet.length; const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER); expectDenied(r); expect(calls.storageSet.length).toBe(before); }); test('denied command never reaches the network and fails at the gate, not the handler', () => { const before = calls.fetch.length; const r = dispatch(listener, { type: 'command', command: 'goto', args: ['https://evil.example'] }, CONTENT_SCRIPT_SENDER); // 'unauthorized' proves the gate fired; the handler's own failure mode is // 'Not connected to browse server'. expect(r.response).toEqual({ error: 'unauthorized' }); expect(calls.fetch.length).toBe(before); }); test('content script can still run the inspector flow (elementPicked → ok)', async () => { const r = dispatch( listener, { type: 'elementPicked', selector: '#hero', tagName: 'div', classes: [], id: null, dimensions: { width: 1, height: 1 } }, CONTENT_SCRIPT_SENDER, ); await new Promise((res) => setTimeout(res, 10)); expect(r.response).toEqual({ ok: true }); }); test('content script can still request openSidePanel (not rejected as unauthorized)', () => { const r = dispatch(listener, { type: 'openSidePanel' }, CONTENT_SCRIPT_SENDER); // chrome.sidePanel is absent in the stub so the handler is a no-op — the // load-bearing assertion is that the gate did not deny it. expect(r.response?.error).toBeUndefined(); }); test('sidepanel getTabState still works (terminal pane tab sync)', async () => { const r = dispatch(listener, { type: 'getTabState' }, PAGE_SENDER); await new Promise((res) => setTimeout(res, 10)); expect(r.responded).toBe(true); expect(r.response).toEqual({ active: null, tabs: [] }); }); }); // ─── Wiring tripwire ──────────────────────────────────────────── // The behavioral suite injects senderAuth directly, so pin that the real // worker actually loads it: importScripts of the helper file plus a // denialFor call in the listener. A refactor that drops either fails here. describe('background.js ↔ sender-auth.js wiring', () => { test('background.js importScripts sender-auth.js (classic worker load path)', () => { expect(BG_SRC).toContain("importScripts('sender-auth.js')"); }); test('background.js consults gstackSenderAuth.denialFor in the message listener', () => { expect(BG_SRC).toContain('gstackSenderAuth.denialFor(msg.type, sender, chrome.runtime.id)'); }); test('manifest keeps a classic (non-module) service worker — importScripts requires it', () => { const manifest = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf-8')); expect(manifest.background.service_worker).toBe('background.js'); expect(manifest.background.type).toBeUndefined(); }); });