This commit is contained in:
1 parent
7b43d260a2
commit
4357ce4094
9 files changed
+337
-100
No files matched your search
@@ -227,6 +227,11 @@ async function injectFreshToken(url) {
|
||||
// re-query storage for a freshly-rotated token.
|
||||
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(
|
||||
async (details) => {
|
||||
if (details.tabId < 0) return;
|
||||
@@ -235,6 +240,28 @@ chrome.webRequest.onBeforeRequest.addListener(
|
||||
if (isVideoRequest(details.url)) {
|
||||
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180));
|
||||
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)
|
||||
@@ -258,7 +285,7 @@ chrome.webRequest.onBeforeRequest.addListener(
|
||||
);
|
||||
|
||||
// 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
|
||||
// therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys
|
||||
// are). Stale referers are pruned opportunistically in persistReferer.
|
||||
@@ -308,87 +335,74 @@ function hostOf(url) {
|
||||
try { return new URL(url).host.toLowerCase(); } catch { return ''; }
|
||||
}
|
||||
|
||||
// Best-effort origin for a host when no referer was captured for it. Used only
|
||||
// as a fallback; a captured referer always wins.
|
||||
function originFromHost(host) {
|
||||
return host ? 'https://' + host + '/' : '';
|
||||
// Registrable domain of a CDN host — the label a single provider's referer
|
||||
// applies to. A referer-protecting CDN serves manifests and segments from many
|
||||
// edge subdomains that share ONE
|
||||
// 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
|
||||
// persist. Referers are short-lived session tokens; this keeps the per-host
|
||||
// global store from accumulating stale entries without tying cleanup to tab
|
||||
// close (a still-open source tab's referer must survive other tabs closing).
|
||||
const REFERER_TTL_MS = 30 * 60 * 1000;
|
||||
// persist. Kept long enough for the scan-now / watch-later flow (a user scans a
|
||||
// page, then opens the viewer much later) without tying cleanup to tab close (a
|
||||
// still-open source tab's referer must survive other tabs closing).
|
||||
const REFERER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Persist the captured referer per CDN host so the proxy can re-use it even
|
||||
// after this capture tab closes. Keyed by host so a proxy for that host can
|
||||
// look its referer 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).
|
||||
// Persist the captured referer per CDN registrable domain so the proxy can
|
||||
// re-use it even after the capture tab closes. Keyed by the host's registrable
|
||||
// domain so a single provider referer covers every edge subdomain (a CDN serves
|
||||
// manifests/segments from many subhosts that share one referer), and so a proxy
|
||||
// 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) {
|
||||
if (!host || !referer) return;
|
||||
const domain = registrableDomain(host);
|
||||
if (!domain || !referer) return;
|
||||
try {
|
||||
const stored = await chrome.storage.local.get('goon_referers');
|
||||
const map = stored['goon_referers'] || {};
|
||||
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)) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
const current = map[host];
|
||||
const current = map[domain];
|
||||
if (current && current.referer === referer) return;
|
||||
map[host] = { referer, at: now };
|
||||
map[domain] = { referer, at: now };
|
||||
await chrome.storage.local.set({ goon_referers: map });
|
||||
} catch (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
|
||||
// (or the stored entry is malformed).
|
||||
// Look up the referer captured for a host's registrable domain. '' when none
|
||||
// has been captured (or the stored entry is malformed).
|
||||
async function getReferer(host) {
|
||||
if (!host) return '';
|
||||
try {
|
||||
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 : '';
|
||||
} catch {
|
||||
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
|
||||
// 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
|
||||
@@ -404,7 +418,8 @@ const refererRuleInstalled = new Map();
|
||||
const refererRuleLocks = new Map();
|
||||
|
||||
async function ensureRefererRule(host, referer) {
|
||||
if (!host || !referer) return;
|
||||
const domain = registrableDomain(host);
|
||||
if (!domain || !referer) return;
|
||||
const api = chrome.declarativeNetRequest;
|
||||
if (!api || typeof api.updateSessionRules !== 'function') {
|
||||
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.
|
||||
const run = (async () => {
|
||||
if (refererRuleInstalled.has(host) && refererRuleInstalled.get(host) === referer) {
|
||||
if (refererRuleInstalled.has(domain) && refererRuleInstalled.get(domain) === referer) {
|
||||
return;
|
||||
}
|
||||
const ruleId = refererRuleIdFor(host);
|
||||
const ruleId = refererRuleIdFor(domain);
|
||||
try {
|
||||
// Remove only THIS host's own rule id, then add it back scoped to the host.
|
||||
await api.updateSessionRules({
|
||||
@@ -435,17 +450,17 @@ async function ensureRefererRule(host, referer) {
|
||||
condition: {
|
||||
// Host + subdomains ONLY, so only proxy fetches for this host get the
|
||||
// rewritten referer; the source page's own requests keep theirs.
|
||||
requestDomains: [host],
|
||||
requestDomains: [domain],
|
||||
},
|
||||
}],
|
||||
});
|
||||
refererRuleInstalled.set(host, referer);
|
||||
console.log('[goon-bg] DNR referer rule installed for', host, '(' + ruleId + ')');
|
||||
refererRuleInstalled.set(domain, referer);
|
||||
console.log('[goon-bg] DNR referer rule installed for', domain, '(' + ruleId + ')');
|
||||
} catch (e) {
|
||||
console.warn('[goon-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
|
||||
}
|
||||
})().finally(() => { refererRuleLocks.set(host, null); });
|
||||
refererRuleLocks.set(host, run);
|
||||
})().finally(() => { refererRuleLocks.set(domain, null); });
|
||||
refererRuleLocks.set(domain, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
@@ -533,10 +548,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
const proxyHost = hostOf(message.url);
|
||||
if (proxyHost) {
|
||||
// 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 referer = captured || message.referer || originFromHost(proxyHost);
|
||||
await ensureRefererRule(proxyHost, referer);
|
||||
const referer = captured || message.referer || '';
|
||||
if (referer) await ensureRefererRule(proxyHost, referer);
|
||||
}
|
||||
// hls.js resolves sub-playslists/segments relative to the master
|
||||
// and can DROP the token query, handing us a token-less
|
||||
@@ -571,6 +589,24 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
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:
|
||||
console.warn('[goon-plugin] Unknown message type:', message.type);
|
||||
}
|
||||
|
||||
@@ -1147,6 +1147,7 @@ async function scanVideos(tabsToScan) {
|
||||
}
|
||||
|
||||
const items = [...seen.values()];
|
||||
await attachReferers(items);
|
||||
const skipped = scrapeResults.filter(r => r === null).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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
async function storeVideos(items) {
|
||||
const stored = items.map(v => ({
|
||||
@@ -1165,6 +1191,7 @@ async function storeVideos(items) {
|
||||
tab: v.tab,
|
||||
mediaType: v.mediaType || 'video',
|
||||
hlsManifest: v.hlsManifest || undefined,
|
||||
referer: v.referer || undefined,
|
||||
}));
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEY]: stored,
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"type": "string",
|
||||
"enum": ["direct", "hls"],
|
||||
"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,
|
||||
|
||||
+33
-9
@@ -178,7 +178,7 @@ const panelsEl = document.getElementById('panels');
|
||||
// Route through the service worker to bypass the CDN's CORS/Referer
|
||||
// restrictions. The SW adds the required Referer and returns base64.
|
||||
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;
|
||||
const lastErr = chrome.runtime.lastError;
|
||||
if (lastErr || !resp || !resp.ok) {
|
||||
@@ -264,19 +264,34 @@ HlsLoader.canSupportLoader = function () { return true; };
|
||||
// hardcode a particular CDN.
|
||||
const PROXY_HOSTS_KEY = 'goon_proxy_hosts';
|
||||
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 () => {
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(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 */ }
|
||||
})();
|
||||
|
||||
// Origin for a URL's host — used as the Referer to request when a proxy fetch
|
||||
// is made for that host (the SW also captures the live referer per host).
|
||||
function originForUrl(url) {
|
||||
if (!url) return '';
|
||||
try { return new URL(url).origin + '/'; } catch { return ''; }
|
||||
// Registrable domain of a CDN host — the label a single provider referer
|
||||
// applies to (a referer-protecting CDN serves from many edge subdomains that
|
||||
// share one referer). Last-two-labels heuristic; mirrors the SW's copy.
|
||||
function registrableDomainOf(host) {
|
||||
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
|
||||
@@ -284,7 +299,7 @@ HlsLoader.canSupportLoader = function () { return true; };
|
||||
function needsProxy(url) {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
const host = hostOfUrl(url);
|
||||
return host ? proxyHosts.has(host) : false;
|
||||
return host ? proxyHosts.has(registrableDomainOf(host)) : false;
|
||||
}
|
||||
|
||||
// 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
|
||||
// requests are learned as referer-protected and persisted so the viewer
|
||||
// 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 blockedHosts = new Set();
|
||||
for (const url of [...new Set(urlsToTest)]) {
|
||||
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));
|
||||
}
|
||||
if (blockedHosts.size > 0) {
|
||||
|
||||
Reference in new issue
Block a user