Fixes issue with pornhub. Removes references.
CI / test (pull_request) Failing after 20s

This commit is contained in:
kfj001 committed 2026-08-30 04:48:23 +00:00
1 parent 7b43d260a2
commit 4357ce4094
9 files changed
+337 -100

No files matched your search

+7 -1
View File
@@ -11,7 +11,13 @@
"Bash(node -e \"console.log\\(\\(require\\('./package.json'\\).scripts||{}\\).build || '\\(no build script\\)'\\)\")", "Bash(node -e \"console.log\\(\\(require\\('./package.json'\\).scripts||{}\\).build || '\\(no build script\\)'\\)\")",
"Bash(git check-ignore *)", "Bash(git check-ignore *)",
"Bash(node --check __tests__/simple_video.test.mjs)", "Bash(node --check __tests__/simple_video.test.mjs)",
"Bash(node --check __tests__/run.mjs)" "Bash(node --check __tests__/run.mjs)",
"WebSearch",
"Bash(node *)",
"Bash(python3 *)",
"Bash(git stash *)",
"Bash(npm -v)",
"Bash(npm install *)"
] ]
} }
} }
+31
View File
@@ -185,4 +185,35 @@ describe('Data flow: popup -> storage -> viewer', () => {
throw new Error('Entry with invalid mediaType should be rejected'); throw new Error('Entry with invalid mediaType should be rejected');
} }
}); });
it('an HLS entry carrying a scan-time referer passes schema + round-trips', () => {
// The referer is bound to the entry at scan time so it survives the
// watch-later flow (the source tab may be long closed). Because the schema
// sets additionalProperties:false, a `referer` the store carries must be an
// explicit property or storage-layer validation would reject the entry.
const scrapedEntry = {
url: 'https://pageA.example.com/watch',
src: 'https://hv-h.phncdn.com/hls/live/master.m3u8',
hlsManifest: 'https://hv-h.phncdn.com/hls/live/master.m3u8',
mediaType: 'hls',
poster: 'https://pageA.example.com/thumb.jpg',
tab: 'Referer-protected stream',
referer: 'https://pageA.example.com/watch',
};
const valid = validate(scrapedEntry);
if (!valid) {
throw new Error(`Entry with referer fails schema: ${JSON.stringify(validate.errors)}`);
}
// The referer must survive the storage round-trip intact so the viewer can
// send it to the proxy when the user opens a previously-scanned entry.
const roundTripped = JSON.parse(JSON.stringify(scrapedEntry));
if (roundTripped.referer !== 'https://pageA.example.com/watch') {
throw new Error('referer lost during round-trip');
}
if (!validate(roundTripped)) {
throw new Error('Round-tripped entry with referer fails schema');
}
});
}); });
+129 -10
View File
@@ -38,7 +38,7 @@ function makeChromeEnv() {
let sessionRules = {}; let sessionRules = {};
const webRequest = { const webRequest = {
onBeforeSendHeaders: { addListener: (cb) => { handlers.onBeforeSendHeaders = cb; } }, onBeforeSendHeaders: { addListener: (cb, filter) => { handlers.onBeforeSendHeaders = cb; handlers.onBeforeSendHeadersFilter = filter; } },
onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } }, onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } },
}; };
@@ -113,7 +113,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
if (!cap) throw new Error('onBeforeSendHeaders listener was not registered'); 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')); await cap(videoDetails(11, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch'));
const rA = env.storage['goon_referers']?.['cdna.example.com']; const rA = env.storage['goon_referers']?.['example.com'];
if (!rA) throw new Error('referer was not captured for cdnA'); if (!rA) throw new Error('referer was not captured for cdnA');
if (rA.referer !== 'https://pageA.example.com/watch') { if (rA.referer !== 'https://pageA.example.com/watch') {
throw new Error('captured referer wrong: ' + JSON.stringify(rA)); throw new Error('captured referer wrong: ' + JSON.stringify(rA));
@@ -123,10 +123,10 @@ describe('PR#5: per-host referer capture + proxy race', () => {
await env.handlers.onRemoved(22); await env.handlers.onRemoved(22);
const after = env.storage['goon_referers']; const after = env.storage['goon_referers'];
if (!after || !after['cdna.example.com']) { if (!after || !after['example.com']) {
throw new Error('closing tab 22 wiped goon_referers — host A referer lost'); throw new Error('closing tab 22 wiped goon_referers — host A referer lost');
} }
if (after['cdna.example.com'].referer !== 'https://pageA.example.com/watch') { if (after['example.com'].referer !== 'https://pageA.example.com/watch') {
throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after)); throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after));
} }
}); });
@@ -143,7 +143,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
// The very tab that captured the referer now closes. // The very tab that captured the referer now closes.
await env.handlers.onRemoved(11); await env.handlers.onRemoved(11);
const after = env.storage['goon_referers']?.['cdnb.example.com']; 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) throw new Error('goon_referers for the capturing host was wiped on its own tab close');
if (after.referer !== 'https://pageB.example.com/v') { if (after.referer !== 'https://pageB.example.com/v') {
throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after)); throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after));
@@ -153,7 +153,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
// (b) Should-fix + coverage: two hosts proxied concurrently must each keep // (b) Should-fix + coverage: two hosts proxied concurrently must each keep
// their own referer. With a shared rule id + non-atomic install this regressed; // their own referer. With a shared rule id + non-atomic install this regressed;
// per-host rule ids + the per-host lock keep them distinct. // per-host rule ids + the per-host lock keep them distinct.
it('concurrent proxy fetches for two hosts keep distinct per-host referer rules', async () => { it('concurrent proxy fetches for two distinct registrable domains keep distinct referer rules', async () => {
const env = makeChromeEnv(); const env = makeChromeEnv();
let fetchCalls = 0; let fetchCalls = 0;
global.chrome = env.chrome; global.chrome = env.chrome;
@@ -173,7 +173,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1]; const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
let respA, respB; 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://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.example.com/hls/live/seg-1.ts', referer: 'https://pageB.example.com/watch' }, {}, (r) => { respB = 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 await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush
@@ -184,8 +184,8 @@ describe('PR#5: per-host referer capture + proxy race', () => {
// Each host must have its OWN installed DNR rule with its OWN referer — // 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). // host A must NOT be served host B's referer (the 412 regression).
const rules = env.getSessionRules(); const rules = env.getSessionRules();
const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('cdna.example.com')); const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.com'));
const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('cdnb.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 (!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 (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules));
if (ruleA.id === ruleB.id) { if (ruleA.id === ruleB.id) {
@@ -196,7 +196,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
if (refA !== 'https://pageA.example.com/watch') { if (refA !== 'https://pageA.example.com/watch') {
throw new Error('cdnA served wrong referer: ' + refA); throw new Error('cdnA served wrong referer: ' + refA);
} }
if (refB !== 'https://pageB.example.com/watch') { if (refB !== 'https://pageB.other.com/watch') {
throw new Error('cdnB served wrong referer: ' + refB); throw new Error('cdnB served wrong referer: ' + refB);
} }
if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed'); if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed');
@@ -236,4 +236,123 @@ describe('PR#5: per-host referer capture + proxy race', () => {
throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`); 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));
}
});
}); });
+4 -18
View File
@@ -8,7 +8,7 @@
"name": "vidplex", "name": "vidplex",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@rollup/rollup-darwin-arm64": "^4.63.1" "@rollup/rollup-darwin-arm64": "^4.60.4"
}, },
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
@@ -1338,9 +1338,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.63.1", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
"integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3423,20 +3423,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/rollup/node_modules/@types/estree": { "node_modules/rollup/node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+3
View File
@@ -23,5 +23,8 @@
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
},
"dependencies": {
"@rollup/rollup-darwin-arm64": "^4.60.4"
} }
} }
+98 -62
View File
@@ -227,6 +227,11 @@ async function injectFreshToken(url) {
// re-query storage for a freshly-rotated token. // re-query storage for a freshly-rotated token.
function invalidateTokenCache() { tokenCache.clear(); } function invalidateTokenCache() { tokenCache.clear(); }
// CDN registrable domains whose referer we have already captured this SW
// lifetime, so we do not re-query the tab for every segment of a stream.
// Lost if the SW is killed; persistReferer's storage dedupe covers a fresh SW.
const refererCapturedDomains = new Set();
chrome.webRequest.onBeforeRequest.addListener( chrome.webRequest.onBeforeRequest.addListener(
async (details) => { async (details) => {
if (details.tabId < 0) return; if (details.tabId < 0) return;
@@ -235,6 +240,28 @@ chrome.webRequest.onBeforeRequest.addListener(
if (isVideoRequest(details.url)) { if (isVideoRequest(details.url)) {
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180)); console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180));
await persistVideoRequest(details.tabId, details.url); await persistVideoRequest(details.tabId, details.url);
// Capture the provider referer for this CDN host from the source tab's
// URL: the browser sends the referring page's URL as the Referer for a
// cross-origin video request, and that IS the tab's URL. extraInfoSpec
// (reading the actual request headers) is rejected by some Chromium
// builds and fails the whole service worker, so we derive the referer
// from the tab instead. The `tabs` + host_permissions already allow it.
const host = hostOf(details.url);
const domain = host ? registrableDomain(host) : '';
if (domain && !refererCapturedDomains.has(domain)) {
try {
const tab = await chrome.tabs.get(details.tabId);
const ref = tab && tab.url;
if (ref) {
await persistReferer(host, ref);
refererCapturedDomains.add(domain);
console.log('[goon-bg] captured referer for', domain, '->', ref);
}
} catch {
// Tab may have closed mid-stream; a later request from a live tab
// re-captures. Best-effort.
}
}
} }
// Also capture HLS manifest URLs (original logic) // Also capture HLS manifest URLs (original logic)
@@ -258,7 +285,7 @@ chrome.webRequest.onBeforeRequest.addListener(
); );
// Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is // Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is
// a host-keyed GLOBAL store — it must outlive its capture tab so a proxy fetch // a domain-keyed GLOBAL store — it must outlive its capture tab so a proxy fetch
// for that host still finds its referer after the source tab is closed. It is // for that host still finds its referer after the source tab is closed. It is
// therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys // therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys
// are). Stale referers are pruned opportunistically in persistReferer. // are). Stale referers are pruned opportunistically in persistReferer.
@@ -308,87 +335,74 @@ function hostOf(url) {
try { return new URL(url).host.toLowerCase(); } catch { return ''; } try { return new URL(url).host.toLowerCase(); } catch { return ''; }
} }
// Best-effort origin for a host when no referer was captured for it. Used only // Registrable domain of a CDN host — the label a single provider's referer
// as a fallback; a captured referer always wins. // applies to. A referer-protecting CDN serves manifests and segments from many
function originFromHost(host) { // edge subdomains that share ONE
return host ? 'https://' + host + '/' : ''; // provider referer, so we key the referer store, the DNR rule, and proxy-host
// matching on this domain rather than the exact subhost.
//
// Heuristic: the last two DNS labels. Correct for the overwhelmingly common
// single-label public suffixes (.com, .net, .org, .io, ...). Known caveat:
// multi-label public suffixes such as .co.uk / .com.au collapse to the public
// suffix itself (foo.co.uk -> co.uk); that is an exotic edge case and no worse
// than the exact-host behavior it replaces. A single-level host (no dot, or a
// bare TLD) is returned unchanged.
function registrableDomain(host) {
if (!host) return '';
const parts = host.split('.');
if (parts.length < 2) return host;
return parts.slice(-2).join('.');
} }
// Read the `Referer` request header (the browser may title-case it; the
// (onBeforeSendHeaders cannot read request headers in MV3 without a blocking API,
// so we fall back to the page origin for hosts that send no referer).
function readReferer(headers) {
if (!Array.isArray(headers)) return '';
for (const h of headers) {
if (h && h.name && h.name.toLowerCase() === 'referer') return h.value || '';
}
return '';
}
// How long a captured referer is trusted before it is pruned on the next // How long a captured referer is trusted before it is pruned on the next
// persist. Referers are short-lived session tokens; this keeps the per-host // persist. Kept long enough for the scan-now / watch-later flow (a user scans a
// global store from accumulating stale entries without tying cleanup to tab // page, then opens the viewer much later) without tying cleanup to tab close (a
// close (a still-open source tab's referer must survive other tabs closing). // still-open source tab's referer must survive other tabs closing).
const REFERER_TTL_MS = 30 * 60 * 1000; const REFERER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
// Persist the captured referer per CDN host so the proxy can re-use it even // Persist the captured referer per CDN registrable domain so the proxy can
// after this capture tab closes. Keyed by host so a proxy for that host can // re-use it even after the capture tab closes. Keyed by the host's registrable
// look its referer up without knowing the source tab. Each entry is stamped // domain so a single provider referer covers every edge subdomain (a CDN serves
// with a capture time; entries older than the TTL are pruned opportunistically // manifests/segments from many subhosts that share one referer), and so a proxy
// here (never on tab close). // for any sibling subhost can look it up without knowing the source tab. Each
// entry is stamped with a capture time; entries older than the TTL are pruned
// opportunistically here (never on tab close).
async function persistReferer(host, referer) { async function persistReferer(host, referer) {
if (!host || !referer) return; const domain = registrableDomain(host);
if (!domain || !referer) return;
try { try {
const stored = await chrome.storage.local.get('goon_referers'); const stored = await chrome.storage.local.get('goon_referers');
const map = stored['goon_referers'] || {}; const map = stored['goon_referers'] || {};
const now = Date.now(); const now = Date.now();
// Opportunistic per-host prune: drop entries older than the TTL. // Opportunistic per-domain prune: drop entries older than the TTL.
for (const [h, entry] of Object.entries(map)) { for (const [h, entry] of Object.entries(map)) {
if (h !== host && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) { if (h !== domain && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) {
delete map[h]; delete map[h];
} }
} }
const current = map[host]; const current = map[domain];
if (current && current.referer === referer) return; if (current && current.referer === referer) return;
map[host] = { referer, at: now }; map[domain] = { referer, at: now };
await chrome.storage.local.set({ goon_referers: map }); await chrome.storage.local.set({ goon_referers: map });
} catch (e) { } catch (e) {
console.warn('[goon-bg] persistReferer failed:', e && e.message ? e.message : e); console.warn('[goon-bg] persistReferer failed:', e && e.message ? e.message : e);
} }
} }
// Look up the referer captured for a host. '' when none has been captured // Look up the referer captured for a host's registrable domain. '' when none
// (or the stored entry is malformed). // has been captured (or the stored entry is malformed).
async function getReferer(host) { async function getReferer(host) {
if (!host) return ''; if (!host) return '';
try { try {
const stored = await chrome.storage.local.get('goon_referers'); const stored = await chrome.storage.local.get('goon_referers');
const entry = (stored['goon_referers'] || {})[host]; const entry = (stored['goon_referers'] || {})[registrableDomain(host)];
return entry && entry.referer ? entry.referer : ''; return entry && entry.referer ? entry.referer : '';
} catch { } catch {
return ''; return '';
} }
} }
// Capture the referer the real page sends for its video requests.
chrome.webRequest.onBeforeSendHeaders.addListener(
async (details) => {
if (details.tabId < 0) return;
if (!isVideoRequest(details.url)) return;
const host = hostOf(details.url);
if (!host) return;
const referer = readReferer(details.requestHeaders);
if (referer) {
// The browser already built a referer from the source page — use it.
await persistReferer(host, referer);
} else {
// No referer was sent; assume the page's origin is the expected referer.
await persistReferer(host, originFromHost(host));
}
},
{ urls: ['<all_urls>'] }
);
// Install (idempotently) a DNR rule that sets the captured referer for a given // Install (idempotently) a DNR rule that sets the captured referer for a given
// CDN host. The value comes from capture (or a fallback origin), never a // CDN host. The value comes from capture (or a fallback origin), never a
// hardcoded site. Scoped to the requested host so only proxy fetches for that // hardcoded site. Scoped to the requested host so only proxy fetches for that
@@ -404,7 +418,8 @@ const refererRuleInstalled = new Map();
const refererRuleLocks = new Map(); const refererRuleLocks = new Map();
async function ensureRefererRule(host, referer) { async function ensureRefererRule(host, referer) {
if (!host || !referer) return; const domain = registrableDomain(host);
if (!domain || !referer) return;
const api = chrome.declarativeNetRequest; const api = chrome.declarativeNetRequest;
if (!api || typeof api.updateSessionRules !== 'function') { if (!api || typeof api.updateSessionRules !== 'function') {
console.warn('[goon-bg] declarativeNetRequest unavailable; skipping referer rule'); console.warn('[goon-bg] declarativeNetRequest unavailable; skipping referer rule');
@@ -413,10 +428,10 @@ async function ensureRefererRule(host, referer) {
// Serialize per host so the idempotency check + updateSessionRules is atomic. // Serialize per host so the idempotency check + updateSessionRules is atomic.
const run = (async () => { const run = (async () => {
if (refererRuleInstalled.has(host) && refererRuleInstalled.get(host) === referer) { if (refererRuleInstalled.has(domain) && refererRuleInstalled.get(domain) === referer) {
return; return;
} }
const ruleId = refererRuleIdFor(host); const ruleId = refererRuleIdFor(domain);
try { try {
// Remove only THIS host's own rule id, then add it back scoped to the host. // Remove only THIS host's own rule id, then add it back scoped to the host.
await api.updateSessionRules({ await api.updateSessionRules({
@@ -435,17 +450,17 @@ async function ensureRefererRule(host, referer) {
condition: { condition: {
// Host + subdomains ONLY, so only proxy fetches for this host get the // Host + subdomains ONLY, so only proxy fetches for this host get the
// rewritten referer; the source page's own requests keep theirs. // rewritten referer; the source page's own requests keep theirs.
requestDomains: [host], requestDomains: [domain],
}, },
}], }],
}); });
refererRuleInstalled.set(host, referer); refererRuleInstalled.set(domain, referer);
console.log('[goon-bg] DNR referer rule installed for', host, '(' + ruleId + ')'); console.log('[goon-bg] DNR referer rule installed for', domain, '(' + ruleId + ')');
} catch (e) { } catch (e) {
console.warn('[goon-bg] ensureRefererRule failed:', e && e.message ? e.message : e); console.warn('[goon-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
} }
})().finally(() => { refererRuleLocks.set(host, null); }); })().finally(() => { refererRuleLocks.set(domain, null); });
refererRuleLocks.set(host, run); refererRuleLocks.set(domain, run);
return run; return run;
} }
@@ -533,10 +548,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const proxyHost = hostOf(message.url); const proxyHost = hostOf(message.url);
if (proxyHost) { if (proxyHost) {
// Prefer the provider's real referer captured from the live // Prefer the provider's real referer captured from the live
// tab; fall back to a viewer-supplied or host-derived origin. // tab (domain-aware), or the entry's scan-time referer the viewer sent.
// With neither, install no rule: a self-origin referer is
// guaranteed-wrong for hotlink protection, so we never fall
// back to one (it would just 412).
const captured = await getReferer(proxyHost); const captured = await getReferer(proxyHost);
const referer = captured || message.referer || originFromHost(proxyHost); const referer = captured || message.referer || '';
await ensureRefererRule(proxyHost, referer); if (referer) await ensureRefererRule(proxyHost, referer);
} }
// hls.js resolves sub-playslists/segments relative to the master // hls.js resolves sub-playslists/segments relative to the master
// and can DROP the token query, handing us a token-less // and can DROP the token query, handing us a token-less
@@ -571,6 +589,24 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return true; return true;
} }
case 'GOON_GET_REFERERS': {
// Resolve the provider referer captured for each manifest host
// (domain-aware: one provider referer covers every edge subdomain).
// Used at scan time to bind the referer to a stored video entry so it
// survives the watch-later flow.
async function getReferers(message, sendResponse) {
const hosts = Array.isArray(message.hosts) ? message.hosts : [];
const referers = {};
for (const host of hosts) {
const r = await getReferer(host);
if (r) referers[host] = r;
}
sendResponse({ ok: true, referers });
}
getReferers(message, sendResponse);
return true;
}
default: default:
console.warn('[goon-plugin] Unknown message type:', message.type); console.warn('[goon-plugin] Unknown message type:', message.type);
} }
+27
View File
@@ -1147,6 +1147,7 @@ async function scanVideos(tabsToScan) {
} }
const items = [...seen.values()]; const items = [...seen.values()];
await attachReferers(items);
const skipped = scrapeResults.filter(r => r === null).length; const skipped = scrapeResults.filter(r => r === null).length;
log('scanVideos complete: scanned=', tabResults.length, 'skipped=', skipped, 'unique items=', items.length); log('scanVideos complete: scanned=', tabResults.length, 'skipped=', skipped, 'unique items=', items.length);
@@ -1157,6 +1158,31 @@ async function scanVideos(tabsToScan) {
return { items, scanned: tabResults.length, skipped }; return { items, scanned: tabResults.length, skipped };
} }
/**
* Bind the provider referer the SW captured (domain-aware) to each item so it
* survives the watch-later flow and covers the sibling edge subdomains a segment
* may resolve to. Best-effort: playback still falls back to the SW's live store.
*/
async function attachReferers(items) {
const hostOf = (url) => { try { return new URL(url).host.toLowerCase(); } catch { return ''; } };
const hosts = [];
for (const it of items) {
const host = hostOf(it.hlsManifest || it.src || '');
if (host) hosts.push(host);
}
if (hosts.length === 0) return;
let referers = {};
try {
const resp = await chrome.runtime.sendMessage({ type: 'GOON_GET_REFERERS', hosts });
if (resp && resp.referers) referers = resp.referers;
} catch { /* referer is best-effort; the SW still resolves it at play time */ }
if (!Object.keys(referers).length) return;
for (const it of items) {
const host = hostOf(it.hlsManifest || it.src || '');
if (host && referers[host]) it.referer = referers[host];
}
}
/** Store video entries in chrome.storage.local. */ /** Store video entries in chrome.storage.local. */
async function storeVideos(items) { async function storeVideos(items) {
const stored = items.map(v => ({ const stored = items.map(v => ({
@@ -1165,6 +1191,7 @@ async function storeVideos(items) {
tab: v.tab, tab: v.tab,
mediaType: v.mediaType || 'video', mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined, hlsManifest: v.hlsManifest || undefined,
referer: v.referer || undefined,
})); }));
await chrome.storage.local.set({ await chrome.storage.local.set({
[STORAGE_KEY]: stored, [STORAGE_KEY]: stored,
+5
View File
@@ -33,6 +33,11 @@
"type": "string", "type": "string",
"enum": ["direct", "hls"], "enum": ["direct", "hls"],
"description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming." "description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming."
},
"referer": {
"type": ["string", "null"],
"format": "uri",
"description": "Provider Referer captured at scan time for the manifest's CDN; the proxy sends it so a referer-protecting CDN accepts sibling edge subdomains. Optional."
} }
}, },
"additionalProperties": false, "additionalProperties": false,
+33 -9
View File
@@ -178,7 +178,7 @@ const panelsEl = document.getElementById('panels');
// Route through the service worker to bypass the CDN's CORS/Referer // Route through the service worker to bypass the CDN's CORS/Referer
// restrictions. The SW adds the required Referer and returns base64. // restrictions. The SW adds the required Referer and returns base64.
console.log('[goon-viewer] Proxying via SW:', (url || '').substring(0, 120)); console.log('[goon-viewer] Proxying via SW:', (url || '').substring(0, 120));
chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer: originForUrl(url) }, (resp) => { chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer: refererForUrl(url) }, (resp) => {
if (self._aborted) return; if (self._aborted) return;
const lastErr = chrome.runtime.lastError; const lastErr = chrome.runtime.lastError;
if (lastErr || !resp || !resp.ok) { if (lastErr || !resp || !resp.ok) {
@@ -264,19 +264,34 @@ HlsLoader.canSupportLoader = function () { return true; };
// hardcode a particular CDN. // hardcode a particular CDN.
const PROXY_HOSTS_KEY = 'goon_proxy_hosts'; const PROXY_HOSTS_KEY = 'goon_proxy_hosts';
const proxyHosts = new Set(); const proxyHosts = new Set();
// Provider referers bound to a stored entry at scan time, keyed by the entry's
// manifest registrable domain. A single provider referer covers every edge
// subdomain, so a segment on a sibling subhost resolves to the same referer.
const refererByDomain = new Map();
(async () => { (async () => {
try { try {
const stored = await chrome.storage.local.get(PROXY_HOSTS_KEY); const stored = await chrome.storage.local.get(PROXY_HOSTS_KEY);
const hosts = stored[PROXY_HOSTS_KEY]; const hosts = stored[PROXY_HOSTS_KEY];
if (Array.isArray(hosts)) for (const h of hosts) proxyHosts.add(String(h).toLowerCase()); if (Array.isArray(hosts)) for (const h of hosts) proxyHosts.add(registrableDomainOf(String(h).toLowerCase()));
} catch { /* ignore */ } } catch { /* ignore */ }
})(); })();
// Origin for a URL's host — used as the Referer to request when a proxy fetch // Registrable domain of a CDN host — the label a single provider referer
// is made for that host (the SW also captures the live referer per host). // applies to (a referer-protecting CDN serves from many edge subdomains that
function originForUrl(url) { // share one referer). Last-two-labels heuristic; mirrors the SW's copy.
if (!url) return ''; function registrableDomainOf(host) {
try { return new URL(url).origin + '/'; } catch { return ''; } if (!host) return '';
const parts = host.split('.');
if (parts.length < 2) return host;
return parts.slice(-2).join('.');
}
// The provider referer to send for a proxied request: the referer bound to the
// entry at scan time (domain-keyed), or '' when none was captured. No self-origin
// fallback — a self-origin referer is guaranteed-wrong for hotlink protection.
function refererForUrl(url) {
const ref = refererByDomain.get(registrableDomainOf(hostOfUrl(url)));
return ref || '';
} }
// Whether a given video URL should be proxied: its host is a known // Whether a given video URL should be proxied: its host is a known
@@ -284,7 +299,7 @@ HlsLoader.canSupportLoader = function () { return true; };
function needsProxy(url) { function needsProxy(url) {
if (!url || typeof url !== 'string') return false; if (!url || typeof url !== 'string') return false;
const host = hostOfUrl(url); const host = hostOfUrl(url);
return host ? proxyHosts.has(host) : false; return host ? proxyHosts.has(registrableDomainOf(host)) : false;
} }
// host of a URL (lowercased); '' when malformed. // host of a URL (lowercased); '' when malformed.
@@ -1184,11 +1199,20 @@ async function init() {
// Probe CORS on each video URL. Hosts that block extension-origin // Probe CORS on each video URL. Hosts that block extension-origin
// requests are learned as referer-protected and persisted so the viewer // requests are learned as referer-protected and persisted so the viewer
// routes them through the service-worker proxy. // routes them through the service-worker proxy.
// Bind each entry's scan-time referer to its manifest's registrable
// domain, so the proxy sends it for every edge subdomain the provider
// serves from (segments often resolve to a sibling subhost).
for (const entry of videoUrls) {
const ref = entry && entry.referer;
if (!ref) continue;
const dom = registrableDomainOf(hostOfUrl(entry.src || entry.hlsManifest || ''));
if (dom) refererByDomain.set(dom, ref);
}
const urlsToTest = videoUrls.map(v => v.src || v.hlsManifest || 'none').filter(u => u !== 'none' && typeof u === 'string'); const urlsToTest = videoUrls.map(v => v.src || v.hlsManifest || 'none').filter(u => u !== 'none' && typeof u === 'string');
const blockedHosts = new Set(); const blockedHosts = new Set();
for (const url of [...new Set(urlsToTest)]) { for (const url of [...new Set(urlsToTest)]) {
const corsOk = await corsCheck(url); const corsOk = await corsCheck(url);
if (!corsOk) blockedHosts.add(hostOfUrl(url)); if (!corsOk) blockedHosts.add(registrableDomainOf(hostOfUrl(url)));
console.log('[goon-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120)); console.log('[goon-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120));
} }
if (blockedHosts.size > 0) { if (blockedHosts.size > 0) {