fix(proxy): stop per-tab referer wipe and concurrent DNR rule race
CI / test (pull_request) Successful in 28s

Address robot's review on PR #5:

- [Blocking] 'goon_referers' is a host-keyed GLOBAL store and must outlive
  its capture tab; it was wrongly added to the per-tab onRemoved cleanup, so
  closing any tab wiped referers captured by still-open source tabs (-> 412).
  It is no longer removed on tab close. Stale referers are now pruned
  opportunistically in persistReferer via a per-host timestamp + TTL.

- [Should-fix] the DNR referer rule used a single shared REFERER_RULE_ID with
  a non-atomic check+update, so concurrent proxy fetches for two hosts could
  interleave and leave the wrong host's referer live. Each host now gets its
  own stable rule id (refererRuleIdFor) and the install+record is serialized
  per host via an async lock (refererRuleLocks).

- [Coverage] add __tests__/referer_proxy.test.mjs exercising the new
  referer-capture / proxy-host / onRemoved paths: closing a tab preserves
  goon_referers, two hosts proxied concurrently keep distinct per-host rules,
  and same-host proxy is idempotent.
This commit is contained in:
kfj001 committed 2026-08-25 15:35:24 -07:00
1 parent 66c621825f
commit e3b904783f
3 files changed
+333 -42

No files matched your search

+93 -42
View File
@@ -257,13 +257,17 @@ chrome.webRequest.onBeforeRequest.addListener(
{ urls: ['<all_urls>'] }
);
// Clean up stale entries when tabs are closed
// 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
// 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.
chrome.tabs.onRemoved.addListener(async (tabId) => {
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId, 'goon_referers'];
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
try {
await chrome.storage.local.remove(keys);
} catch { /* ignore */ }
});
} catch { /* ignore */ }
});
// === Capture the provider-origin Referer per CDN host (CDN-agnostic) ===
// Some media CDNs reject requests whose `Referer` is not the provider page's
@@ -282,7 +286,21 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
// GOON_PROXY_FETCH handler then installs a DNR rule that re-inserts that
// captured referer for just that host, so the proxy's fetch() is accepted.
// This requires no knowledge of any particular site.
const REFERER_RULE_ID = 9001;
//
// Each CDN host gets its OWN declarativeNetRequest rule id so that concurrent
// proxy fetches for different hosts can't clobber each other's rule (a single
// shared id + a remove-then-add lets two hosts interleave and leave the wrong
// host's referer live). The id is derived from the host via a monotonic counter
// keyed by host, so it is stable for a given host across installs.
let nextRefererRuleId = 9001;
const refererRuleIds = new Map();
function refererRuleIdFor(host) {
const existing = refererRuleIds.get(host);
if (existing !== undefined) return existing;
const id = nextRefererRuleId++;
refererRuleIds.set(host, id);
return id;
}
// host of a URL (CDN host, lowercased); '' when the URL is malformed.
function hostOf(url) {
@@ -306,31 +324,49 @@ function readReferer(headers) {
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 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.
// 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).
async function persistReferer(host, referer) {
if (!host || !referer) return;
try {
const stored = await chrome.storage.local.get('goon_referers');
const map = stored['goon_referers'] || {};
if (map[host] === referer) return;
map[host] = referer;
const now = Date.now();
// Opportunistic per-host 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)) {
delete map[h];
}
}
const current = map[host];
if (current && current.referer === referer) return;
map[host] = { 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.
// Look up the referer captured for a host. '' 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');
return (stored['goon_referers'] || {})[host] || '';
} catch {
const entry = (stored['goon_referers'] || {})[host];
return entry && entry.referer ? entry.referer : '';
} catch {
return '';
}
}
}
// Capture the referer the real page sends for its video requests.
@@ -356,47 +392,62 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
// 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
// host get the rewritten header.
//
// Each host gets its own rule id (refererRuleIdFor) so that concurrent proxy
// fetches for DIFFERENT hosts never interleave: host A's rule and host B's rule
// are distinct DNR ids and one install can't remove the other. Within a single
// host, a per-host async lock (refererRuleLocks) serializes the idempotency
// check + updateSessionRules so the install-and-record pair is atomic — no two
// installs for the same host can interleave their check/update.
const refererRuleInstalled = new Map();
const refererRuleLocks = new Map();
async function ensureRefererRule(host, referer) {
if (!host || !referer) return;
const api = chrome.declarativeNetRequest;
if (!api || typeof api.updateSessionRules !== 'function') {
console.warn('[goon-bg] declarativeNetRequest unavailable; skipping referer rule');
return;
}
if (refererRuleInstalled.has(host) && refererRuleInstalled.get(host) === referer) {
console.warn('[goon-bg] declarativeNetRequest unavailable; skipping referer rule');
return;
}
}
// Serialize per host so the idempotency check + updateSessionRules is atomic.
const run = (async () => {
if (refererRuleInstalled.has(host) && refererRuleInstalled.get(host) === referer) {
return;
}
const ruleId = refererRuleIdFor(host);
try {
await api.updateSessionRules({
removeRuleIds: [REFERER_RULE_ID],
addRules: [{
id: REFERER_RULE_ID,
priority: 1,
action: {
type: 'modifyHeaders',
// Remove only THIS host's own rule id, then add it back scoped to the host.
await api.updateSessionRules({
removeRuleIds: [ruleId],
addRules: [{
id: ruleId,
priority: 1,
action: {
type: 'modifyHeaders',
// `set` is allowed for any request header (only `append` is restricted
// to a fixed allowlist). `referer` is the header the CDN checks.
requestHeaders: [
{ header: 'referer', operation: 'set', value: referer },
],
},
condition: {
requestHeaders: [
{ header: 'referer', operation: 'set', value: 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],
},
}],
});
refererRuleInstalled.set(host, referer);
console.log('[goon-bg] DNR referer rule installed for', host);
} catch (e) {
requestDomains: [host],
},
}],
});
refererRuleInstalled.set(host, referer);
console.log('[goon-bg] DNR referer rule installed for', host, '(' + ruleId + ')');
} catch (e) {
console.warn('[goon-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
}
}
}
})().finally(() => { refererRuleLocks.set(host, null); });
refererRuleLocks.set(host, run);
return run;
}
// Track which host's referer is currently installed so repeated proxy fetches
// for the same host don't reinstall the same rule.
const refererRuleInstalled = new Map();
// === Message handlers for popup/data consumers ===