359 lines
16 KiB
JavaScript
359 lines
16 KiB
JavaScript
/**
|
|
* referer_proxy.test.mjs — coverage for the background worker's per-CDN-host
|
|
* referer capture + DNR proxy-host learning, added in PR #5.
|
|
*
|
|
* The background worker is not itself a module, so the test transpiles it into
|
|
* one (wrapping + top-level-statement `async function` transpile) under a mocked
|
|
* `chrome` + `fetch`/`btoa` environment, then exercises the captured listeners
|
|
* and the message handler directly.
|
|
*
|
|
* Covers the three gaps robot flagged:
|
|
* (a) closing a tab must NOT wipe the host-keyed `goon_referers` global store,
|
|
* (b) two hosts proxied concurrently must each keep their OWN referer (no race
|
|
* that leaves host B's referer live for host A -> 412),
|
|
* (c) the new referer-capture / proxy-host-learning / onRemoved paths run.
|
|
*/
|
|
import { describe, it } from './runner.mjs';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const backgroundSource = readFileSync(join(__dirname, '../src/background/background.js'), 'utf8');
|
|
|
|
// Transpile the top-level-statement worker into an ES module. Wrapping in an
|
|
// async IIFE makes its top-level `async function` declarations + `await`-using
|
|
// nested functions legal inside a module.
|
|
function buildModule(code) {
|
|
return `(async () => {\n${code}\n})();\n`;
|
|
}
|
|
|
|
// A fresh mock of the chrome APIs the worker touches. `onBeforeSendHeaders`,
|
|
// `onBeforeRequest`, and `tabs.onRemoved` capture their listener so the test can
|
|
// drive them; `declarativeNetRequest.updateSessionRules` records the live rules.
|
|
function makeChromeEnv() {
|
|
const storage = {};
|
|
const handlers = { onMessage: [] };
|
|
let sessionRules = {};
|
|
|
|
const webRequest = {
|
|
onBeforeSendHeaders: { addListener: (cb, filter) => { handlers.onBeforeSendHeaders = cb; handlers.onBeforeSendHeadersFilter = filter; } },
|
|
onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } },
|
|
};
|
|
|
|
const chrome = {
|
|
storage: {
|
|
local: {
|
|
get: async (keys) => {
|
|
const out = {};
|
|
const arr = Array.isArray(keys) ? keys : (keys === undefined ? Object.keys(storage) : [keys]);
|
|
for (const k of arr) if (k in storage) out[k] = storage[k];
|
|
return out;
|
|
},
|
|
set: async (data) => {
|
|
for (const [k, v] of Object.entries(data)) storage[k] = v;
|
|
},
|
|
remove: async (keys) => {
|
|
const arr = Array.isArray(keys) ? keys : [keys];
|
|
for (const k of arr) delete storage[k];
|
|
},
|
|
},
|
|
},
|
|
webRequest,
|
|
tabs: {
|
|
onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } },
|
|
},
|
|
runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } },
|
|
declarativeNetRequest: {
|
|
updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => {
|
|
const rm = new Set(removeRuleIds);
|
|
sessionRules = Object.fromEntries(
|
|
Object.entries(sessionRules).filter(([id]) => !rm.has(id))
|
|
);
|
|
for (const r of addRules) sessionRules[r.id] = r;
|
|
},
|
|
},
|
|
};
|
|
|
|
return { chrome, handlers, storage, getSessionRules: () => sessionRules };
|
|
}
|
|
|
|
// Load the worker into a fresh environment. `data:` URLs are cached by Node, so
|
|
// the module body (listener registration) would only run once; instead we write
|
|
// a uniquely-named temp file per load so each call registers into its own env.
|
|
let loadCounter = 0;
|
|
async function loadBackground(env) {
|
|
global.chrome = env.chrome;
|
|
const file = join(tmpdir(), `goon_bg_${process.pid}_${loadCounter++}.mjs`);
|
|
writeFileSync(file, buildModule(backgroundSource));
|
|
const module = await import(pathToFileURL(file).href);
|
|
await module.default;
|
|
return module;
|
|
}
|
|
|
|
function videoDetails(tabId, url, referer) {
|
|
return {
|
|
tabId,
|
|
url,
|
|
requestHeaders: referer ? [{ name: 'Referer', value: referer }] : [],
|
|
};
|
|
}
|
|
|
|
describe('PR#5: per-host referer capture + proxy race', () => {
|
|
// (a) Blocking fix: a host-keyed `goon_referers` entry must outlive the close
|
|
// of ANY tab — including a different tab.
|
|
it('closing a tab does not wipe goon_referers captured for another host', async () => {
|
|
const env = makeChromeEnv();
|
|
global.chrome = env.chrome;
|
|
await loadBackground(env);
|
|
|
|
// A live source tab captures the provider referer for its CDN host.
|
|
const cap = env.handlers.onBeforeSendHeaders;
|
|
if (!cap) throw new Error('onBeforeSendHeaders listener was not registered');
|
|
await cap(videoDetails(11, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch'));
|
|
|
|
const rA = env.storage['goon_referers']?.['example.com'];
|
|
if (!rA) throw new Error('referer was not captured for cdnA');
|
|
if (rA.referer !== 'https://pageA.example.com/watch') {
|
|
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
|
|
}
|
|
|
|
// A DIFFERENT tab closes. The per-tab cleanup must NOT remove goon_referers.
|
|
await env.handlers.onRemoved(22);
|
|
|
|
const after = env.storage['goon_referers'];
|
|
if (!after || !after['example.com']) {
|
|
throw new Error('closing tab 22 wiped goon_referers — host A referer lost');
|
|
}
|
|
if (after['example.com'].referer !== 'https://pageA.example.com/watch') {
|
|
throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after));
|
|
}
|
|
});
|
|
|
|
it('closing the capturing tab itself preserves the referer for later proxy use', async () => {
|
|
const env = makeChromeEnv();
|
|
global.chrome = env.chrome;
|
|
await loadBackground(env);
|
|
|
|
await env.handlers.onBeforeSendHeaders(
|
|
videoDetails(11, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v')
|
|
);
|
|
|
|
// The very tab that captured the referer now closes.
|
|
await env.handlers.onRemoved(11);
|
|
|
|
const after = env.storage['goon_referers']?.['example.com'];
|
|
if (!after) throw new Error('goon_referers for the capturing host was wiped on its own tab close');
|
|
if (after.referer !== 'https://pageB.example.com/v') {
|
|
throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after));
|
|
}
|
|
});
|
|
|
|
// (b) Should-fix + coverage: two hosts proxied concurrently must each keep
|
|
// their own referer. With a shared rule id + non-atomic install this regressed;
|
|
// per-host rule ids + the per-host lock keep them distinct.
|
|
it('concurrent proxy fetches for two distinct registrable domains keep distinct referer rules', async () => {
|
|
const env = makeChromeEnv();
|
|
let fetchCalls = 0;
|
|
global.chrome = env.chrome;
|
|
global.fetch = async (url) => {
|
|
fetchCalls++;
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
};
|
|
};
|
|
|
|
await loadBackground(env);
|
|
|
|
// Two GOON_PROXY_FETCH messages for different CDN hosts, fired together.
|
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
let respA, respB;
|
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' }, {}, (r) => { respA = r; });
|
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnB.other.com/hls/live/seg-1.ts', referer: 'https://pageB.other.com/watch' }, {}, (r) => { respB = r; });
|
|
|
|
await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush
|
|
|
|
if (!respA || !respA.ok) throw new Error('proxy A failed: ' + JSON.stringify(respA));
|
|
if (!respB || !respB.ok) throw new Error('proxy B failed: ' + JSON.stringify(respB));
|
|
if (fetchCalls < 2) throw new Error('expected both proxy fetches to run, got ' + fetchCalls);
|
|
|
|
// Each host must have its OWN installed DNR rule with its OWN referer —
|
|
// host A must NOT be served host B's referer (the 412 regression).
|
|
const rules = env.getSessionRules();
|
|
const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.com'));
|
|
const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('other.com'));
|
|
if (!ruleA) throw new Error('no DNR rule installed for cdna; rules=' + JSON.stringify(rules));
|
|
if (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules));
|
|
if (ruleA.id === ruleB.id) {
|
|
throw new Error('cdnA and cdnB shared a DNR rule id -> concurrent race possible');
|
|
}
|
|
const refA = ruleA.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
|
const refB = ruleB.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
|
if (refA !== 'https://pageA.example.com/watch') {
|
|
throw new Error('cdnA served wrong referer: ' + refA);
|
|
}
|
|
if (refB !== 'https://pageB.other.com/watch') {
|
|
throw new Error('cdnB served wrong referer: ' + refB);
|
|
}
|
|
if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed');
|
|
});
|
|
|
|
it('repeated proxy for the same host is idempotent (one rule, one install)', async () => {
|
|
const env = makeChromeEnv();
|
|
let installs = 0;
|
|
env.chrome.declarativeNetRequest.updateSessionRules = async ({ addRules = [] } = {}) => {
|
|
for (const r of addRules) installs++;
|
|
};
|
|
global.chrome = env.chrome;
|
|
global.fetch = async () => ({
|
|
ok: true, status: 200,
|
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
});
|
|
|
|
await loadBackground(env);
|
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
|
|
const run = async (n) => {
|
|
handler(
|
|
{ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' },
|
|
{},
|
|
() => {}
|
|
);
|
|
await new Promise(r => setTimeout(r, 5));
|
|
};
|
|
await run(1);
|
|
const installsAfterFirst = installs;
|
|
await run(2);
|
|
await run(3);
|
|
|
|
// Same host + same referer should not re-add the rule on repeat fetches.
|
|
if (installs !== installsAfterFirst) {
|
|
throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`);
|
|
}
|
|
});
|
|
|
|
// (c) Domain generalization — the core 412 fix. A provider referer captured for
|
|
// ONE edge subdomain must cover a SIBLING subdomain: the DNR rule is scoped to
|
|
// the registrable domain and the store is domain-keyed, so a proxy for ev-h
|
|
// needs no per-subdomain capture.
|
|
it('a proxy for a sibling subdomain reuses the captured referer via the registrable domain', async () => {
|
|
const env = makeChromeEnv();
|
|
global.chrome = env.chrome;
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
});
|
|
await loadBackground(env);
|
|
|
|
// Capture the provider referer for hv-h.phncdn.com (one edge subdomain).
|
|
await env.handlers.onBeforeSendHeaders(
|
|
videoDetails(11, 'https://hv-h.phncdn.com/hls/live/seg-1.ts', 'https://provider.example.com/watch'));
|
|
|
|
// Proxy a SIBLING subdomain ev-h.phncdn.com with NO referer in the message:
|
|
// the SW must resolve it from the domain-keyed store.
|
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
let resp;
|
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://ev-h.phncdn.com/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
|
|
await new Promise(r => setTimeout(r, 20));
|
|
|
|
if (!resp || !resp.ok) throw new Error('sibling proxy failed: ' + JSON.stringify(resp));
|
|
const rules = env.getSessionRules();
|
|
const rule = Object.values(rules).find(r => r.condition?.requestDomains?.includes('phncdn.com'));
|
|
if (!rule) throw new Error('no domain-scoped DNR rule for phncdn.com; rules=' + JSON.stringify(rules));
|
|
const ref = rule.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
|
if (ref !== 'https://provider.example.com/watch') {
|
|
throw new Error('sibling edge served wrong referer: ' + ref);
|
|
}
|
|
// The rule is scoped to the registrable domain, NOT the exact subdomain.
|
|
if (rule.condition.requestDomains?.includes('ev-h.phncdn.com')) {
|
|
throw new Error('rule scoped to exact subdomain instead of registrable domain');
|
|
}
|
|
});
|
|
|
|
// (d) With no captured referer and none in the message, install NO rule: the
|
|
// self-origin fallback (guaranteed-wrong for hotlink protection) is dropped.
|
|
it('no captured referer and no message referer installs no DNR rule', async () => {
|
|
const env = makeChromeEnv();
|
|
let fetchCalls = 0;
|
|
global.chrome = env.chrome;
|
|
global.fetch = async () => {
|
|
fetchCalls++;
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
};
|
|
};
|
|
await loadBackground(env);
|
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
let resp;
|
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
|
|
await new Promise(r => setTimeout(r, 20));
|
|
|
|
if (!resp || !resp.ok) throw new Error('proxy fetch failed: ' + JSON.stringify(resp));
|
|
if (fetchCalls < 1) throw new Error('proxy fetch did not run');
|
|
const rules = env.getSessionRules();
|
|
if (Object.keys(rules).length !== 0) {
|
|
throw new Error('installed a rule with no referer (self-origin fallback not dropped): ' + JSON.stringify(rules));
|
|
}
|
|
});
|
|
|
|
// (e) GOON_GET_REFERERS (used by scan-time capture) resolves each manifest host
|
|
// via the domain-keyed store: a sibling subdomain gets the provider referer,
|
|
// and an uncaptured host is omitted.
|
|
it('GOON_GET_REFERERS resolves manifest hosts through the domain-keyed store', async () => {
|
|
const env = makeChromeEnv();
|
|
global.chrome = env.chrome;
|
|
await loadBackground(env);
|
|
|
|
// Capture for hv-h.phncdn.com only.
|
|
await env.handlers.onBeforeSendHeaders(
|
|
videoDetails(11, 'https://hv-h.phncdn.com/hls/live/seg-1.ts', 'https://provider.example.com/watch'));
|
|
|
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
let resp;
|
|
handler(
|
|
{ type: 'GOON_GET_REFERERS', hosts: ['ev-h.phncdn.com', 'hv-h.phncdn.com', 'cdnA.example.com'] },
|
|
{},
|
|
(r) => { resp = r; });
|
|
await new Promise(r => setTimeout(r, 15));
|
|
|
|
if (!resp || !resp.ok) throw new Error('GOON_GET_REFERERS failed: ' + JSON.stringify(resp));
|
|
const referers = resp.referers || {};
|
|
if (referers['ev-h.phncdn.com'] !== 'https://provider.example.com/watch') {
|
|
throw new Error('sibling subdomain did not resolve the captured referer: ' + JSON.stringify(referers));
|
|
}
|
|
if (referers['hv-h.phncdn.com'] !== 'https://provider.example.com/watch') {
|
|
throw new Error('captured subdomain did not resolve its own referer: ' + JSON.stringify(referers));
|
|
}
|
|
if ('cdnA.example.com' in referers) {
|
|
throw new Error('uncaptured host should be omitted from referers: ' + JSON.stringify(referers));
|
|
}
|
|
});
|
|
|
|
// Regression for the live 412 bug: the capture listener MUST request
|
|
// requestHeaders via extraInfoSpec, or details.requestHeaders is undefined and
|
|
// readReferer() returns '' for every request — so no referer is ever stored,
|
|
// every entry stays referer-less, and the phncdn proxy 412s. The callback-level
|
|
// tests above pass even without this (they hand requestHeaders in directly), so
|
|
// this asserts the registration itself.
|
|
it('the onBeforeSendHeaders capture listener requests requestHeaders via extraInfoSpec', async () => {
|
|
const env = makeChromeEnv();
|
|
global.chrome = env.chrome;
|
|
await loadBackground(env);
|
|
const filter = env.handlers.onBeforeSendHeadersFilter;
|
|
if (!filter) throw new Error('onBeforeSendHeaders listener registered no filter object');
|
|
if (!Array.isArray(filter.extraInfoSpec) || !filter.extraInfoSpec.includes('requestHeaders')) {
|
|
throw new Error('capture listener did not request requestHeaders via extraInfoSpec: ' + JSON.stringify(filter));
|
|
}
|
|
});
|
|
});
|