mirror of https://github.com/garrytan/gstack.git
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
/**
|
|
* 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<string, unknown> | undefined };
|
|
listener(msg, sender, (resp: unknown) => {
|
|
result.responded = true;
|
|
result.response = resp as Record<string, unknown>;
|
|
});
|
|
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<typeof dispatch>) {
|
|
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();
|
|
});
|
|
});
|