F1 BLOCKING — walk() cycle detection (src/viewer/index.js:161)
CI / test (pull_request) Successful in 5m41s
CI / test (pull_request) Successful in 5m41s
Added a per-tree WeakSet visited-guard so the probe terminates. The depth cap alone didn't stop the infinite recursion: window/_parent/_top/frames/_globalObject/_globalProxy are mutually-reachable circular Proxies, so Strategy H's window scan re-walked the same identities along exponentially many paths. The set is declared inside walk (not the outer function) so the separate player trees stay independent. This is what was hanging npm test (exit 124); it now completes in ~15s.
F2 SHOULD-FIX — doc/behavior mismatch (src/background/background.js:299-322)
Reworded the referer-capture comment block. It claimed the referer was read "via onBeforeSendHeaders, which sees the request headers," but the code uses onBeforeRequest + chrome.tabs.get (deriving the referer from the source tab's URL). Now states the actual mechanism and documents why onBeforeSendHeaders+extraHeaders was avoided (extraInfoSpec is rejected by some Chromium builds and fails the whole SW).
F3 SHOULD-FIX — leaked settings file
git rm --cached .claude/settings.local.json and added it to .gitignore. It carried machine-specific absolute paths (/Users/kyle/.claude/...).
F4 FYI — package-lock churn (package-lock.json)
Removed the spurious root "dependencies": { @rollup/rollup-darwin-arm64 } and restored the dev: true / optional: true flags on that platform package so it matches the rest of the @rollup/rollup-* siblings.
F5 FYI — real provider hostnames in test fixtures
Swapped phncdn.com→neutral example.net (registrable-domain sibling test) and thisvid.com→example.com in data_flow/hls_qa/simple_video/referer_proxy test files, rewording comments that named the real sites. src/ and __tests__/ now have zero phncdn/thisvid/pornhub references.
This commit is contained in:
1 parent
ff463c107a
commit
3e02a6ae74
9 files changed
+70
-75
No files matched your search
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)",
|
||||
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)",
|
||||
"Bash(open -a \"Microsoft Edge\" --args --remote-debugging-port=9222 --profile-directory=Default)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(node --check src/viewer/index.js)",
|
||||
"Bash(node /Users/kyle/.claude/jobs/1462df8d/tmp/transform2.mjs)",
|
||||
"Bash(node __tests__/run.mjs)",
|
||||
"Bash(node -e \"console.log\\(\\(require\\('./package.json'\\).scripts||{}\\).build || '\\(no build script\\)'\\)\")",
|
||||
"Bash(git check-ignore *)",
|
||||
"Bash(node --check __tests__/simple_video.test.mjs)",
|
||||
"Bash(node --check __tests__/run.mjs)",
|
||||
"WebSearch",
|
||||
"Bash(node *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(npm -v)",
|
||||
"Bash(npm install *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,4 +2,5 @@ node_modules/
|
||||
dist/
|
||||
.vscode-insiders/
|
||||
/icons
|
||||
/icons/*
|
||||
/icons/*
|
||||
.claude/settings.local.json
|
||||
@@ -193,8 +193,8 @@ describe('Data flow: popup -> storage -> viewer', () => {
|
||||
// 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',
|
||||
src: 'https://cdn.example.com/hls/live/master.m3u8',
|
||||
hlsManifest: 'https://cdn.example.com/hls/live/master.m3u8',
|
||||
mediaType: 'hls',
|
||||
poster: 'https://pageA.example.com/thumb.jpg',
|
||||
tab: 'Referer-protected stream',
|
||||
|
||||
+13
-13
@@ -1262,24 +1262,24 @@ describe('HLS QA: route-template / nav URL rejection', () => {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Reproduces thisvid.com: pageContext.langUrls holds server-side route
|
||||
// Reproduces a provider whose pageContext.langUrls holds server-side route
|
||||
// TEMPLATES / nav links (e.g. /playlists/, /playlist/%1%/video/%2%/) that the
|
||||
// old /playlist|/manifest substring check misclassified as "manifests".
|
||||
const TEMPLATE_URLS = [
|
||||
'https://thisvid.com/playlists/',
|
||||
'https://thisvid.com/playlists/liked/',
|
||||
'https://thisvid.com/playlists/popular/',
|
||||
'https://thisvid.com/playlists/commented/',
|
||||
'https://thisvid.com/playlists/updated/',
|
||||
'https://thisvid.com/playlists/largest/',
|
||||
'https://thisvid.com/playlist/%1%/video/%2%/',
|
||||
'https://thisvid.com/playlists/?category=%DIR%',
|
||||
'https://thisvid.com/members/%ID%/playlists/',
|
||||
'https://thisvid.com/my/playlists/%ID%/',
|
||||
'https://example.com/playlists/',
|
||||
'https://example.com/playlists/liked/',
|
||||
'https://example.com/playlists/popular/',
|
||||
'https://example.com/playlists/commented/',
|
||||
'https://example.com/playlists/updated/',
|
||||
'https://example.com/playlists/largest/',
|
||||
'https://example.com/playlist/%1%/video/%2%/',
|
||||
'https://example.com/playlists/?category=%DIR%',
|
||||
'https://example.com/members/%ID%/playlists/',
|
||||
'https://example.com/my/playlists/%ID%/',
|
||||
];
|
||||
|
||||
it('route templates / nav links are NOT reported as manifests', () => {
|
||||
const result = runProbeWin('https://thisvid.com/videos/redneck/', {
|
||||
const result = runProbeWin('https://example.com/videos/redneck/', {
|
||||
pageContext: { langUrls: TEMPLATE_URLS },
|
||||
kvsplayer: { config: { langUrls: TEMPLATE_URLS } },
|
||||
__goon_plugin_data: { manifests: [], sources: [] },
|
||||
@@ -1294,7 +1294,7 @@ describe('HLS QA: route-template / nav URL rejection', () => {
|
||||
it('a real .m3u8 with a %2B token is still found (no over-rejection)', () => {
|
||||
// %2B is a valid percent-encoded token, not a placeholder — it must survive.
|
||||
const real = 'https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234';
|
||||
const result = runProbeWin('https://thisvid.com/videos/redneck/', {
|
||||
const result = runProbeWin('https://example.com/videos/redneck/', {
|
||||
hls: { url: real },
|
||||
pageContext: { langUrls: TEMPLATE_URLS },
|
||||
});
|
||||
|
||||
@@ -252,26 +252,26 @@ describe('PR#5: per-host referer capture + proxy race', () => {
|
||||
});
|
||||
await loadBackground(env);
|
||||
|
||||
// Capture the provider referer for hv-h.phncdn.com (one edge subdomain).
|
||||
await capture(env, 'https://hv-h.phncdn.com/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
||||
// Capture the provider referer for hv-h.example.net (one edge subdomain).
|
||||
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
||||
|
||||
// Proxy a SIBLING subdomain ev-h.phncdn.com with NO referer in the message:
|
||||
// Proxy a SIBLING subdomain ev-h.example.net 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; });
|
||||
handler({ type: 'GOON_PROXY_FETCH', url: 'https://ev-h.example.net/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 rule = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.net'));
|
||||
if (!rule) throw new Error('no domain-scoped DNR rule for example.net; 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')) {
|
||||
if (rule.condition.requestDomains?.includes('ev-h.example.net')) {
|
||||
throw new Error('rule scoped to exact subdomain instead of registrable domain');
|
||||
}
|
||||
});
|
||||
@@ -313,23 +313,23 @@ describe('PR#5: per-host referer capture + proxy race', () => {
|
||||
global.chrome = env.chrome;
|
||||
await loadBackground(env);
|
||||
|
||||
// Capture for hv-h.phncdn.com only.
|
||||
await capture(env, 'https://hv-h.phncdn.com/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
||||
// Capture for hv-h.example.net only.
|
||||
await capture(env, 'https://hv-h.example.net/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'] },
|
||||
{ type: 'GOON_GET_REFERERS', hosts: ['ev-h.example.net', 'hv-h.example.net', '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') {
|
||||
if (referers['ev-h.example.net'] !== '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') {
|
||||
if (referers['hv-h.example.net'] !== '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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Regression tests: simple <video> / direct-media detection.
|
||||
//
|
||||
// The feature branch broke detection of pages that expose a plain <video>
|
||||
// (e.g. thisvid.com, which serves a direct MP4 with a TRAILING SLASH and a
|
||||
// (e.g. a site that serves a direct MP4 with a TRAILING SLASH and a
|
||||
// ?rnd= query: .../14644093/14644093.mp4/?rnd=...). Two site-agnostic fixes
|
||||
// are guarded here:
|
||||
// 1) The URL predicate (isValidManifestUrl) must let concrete media signals
|
||||
@@ -9,7 +9,7 @@
|
||||
// slash rejections, so a real direct-media URL is not mistaken for a
|
||||
// route template.
|
||||
// 2) scrapeTab must fall back to an ISOLATED-world scrape when the MAIN-world
|
||||
// scrape yields nothing — the runtime cause of "detects nothing" on thisvid
|
||||
// scrape yields nothing — the runtime cause of "detects nothing" on such pages
|
||||
// is a MAIN-world + allFrames rejection on its ad-iframe-heavy page.
|
||||
import { describe, it } from './runner.mjs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
@@ -40,12 +40,12 @@ function extractFn(src, name) {
|
||||
throw new Error('unbalanced: ' + name);
|
||||
}
|
||||
|
||||
// The thisvid direct-media URL, exactly as served (note the trailing slash +
|
||||
// The direct-media URL, shaped exactly like the rejected case (a trailing slash +
|
||||
// ?rnd= query). This is the concrete case the predicate used to reject.
|
||||
const THISVID_MP4 =
|
||||
'https://thisvid.com/get_file/5/6cae22f8b3ba9d74f10cc2f8d060af738c8d30ddf5/14644000/14644093/14644093.mp4/?rnd=1787811205406';
|
||||
const DIRECT_MP4 =
|
||||
'https://example.com/get_file/5/6cae22f8b3ba9d74f10cc2f8d060af738c8d30ddf5/14644000/14644093/14644093.mp4/?rnd=1787811205406';
|
||||
|
||||
describe('HLS QA: simple <video> direct-media predicate (thisvid regression)', () => {
|
||||
describe('HLS QA: simple <video> direct-media predicate', () => {
|
||||
// Extract and evaluate the REAL isValidManifestUrl from index.js in a browser
|
||||
// window so its `new URL(...)` / string behavior matches production.
|
||||
const isValidSrc = extractFn(indexSource, 'isValidManifestUrl');
|
||||
@@ -53,7 +53,7 @@ describe('HLS QA: simple <video> direct-media predicate (thisvid regression)', (
|
||||
function assertValid(url, expectValid, label) {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body></body></html>',
|
||||
{ url: 'https://thisvid.com/videos/x/', runScripts: 'dangerously' }
|
||||
{ url: 'https://example.com/videos/x/', runScripts: 'dangerously' }
|
||||
);
|
||||
const got = dom.window.eval('(' + isValidSrc + ')(' + JSON.stringify(url) + ')');
|
||||
if (got !== expectValid) {
|
||||
@@ -63,9 +63,9 @@ describe('HLS QA: simple <video> direct-media predicate (thisvid regression)', (
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts thisvid\'s real direct-MP4 URL WITH a trailing slash + ?rnd=', () => {
|
||||
it('accepts a real direct-MP4 URL WITH a trailing slash + ?rnd=', () => {
|
||||
// Before the fix, `path.endsWith('/')` rejected this real media source.
|
||||
assertValid(THISVID_MP4, true, 'thisvid trailing-slash MP4');
|
||||
assertValid(DIRECT_MP4, true, 'trailing-slash MP4');
|
||||
});
|
||||
|
||||
it('accepts a direct .mp4 with no trailing slash', () => {
|
||||
@@ -86,18 +86,18 @@ describe('HLS QA: simple <video> direct-media predicate (thisvid regression)', (
|
||||
assertValid('https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234', true, '%2B token');
|
||||
});
|
||||
|
||||
it('still rejects thisvid route-template / nav URLs (no over-acceptance)', () => {
|
||||
it('still rejects route-template / nav URLs (no over-acceptance)', () => {
|
||||
// The fix must not trade one regression for the other: the route templates
|
||||
// thisvid\'s pageContext.langUrls leaks must still be rejected.
|
||||
assertValid('https://thisvid.com/playlists/', false, '/playlists/');
|
||||
assertValid('https://thisvid.com/playlists/liked/', false, '/playlists/liked/');
|
||||
assertValid('https://thisvid.com/playlist/%1%/video/%2%/', false, '/playlist/%1%/');
|
||||
assertValid('https://thisvid.com/members/%ID%/playlists/', false, '/members/%ID%/playlists/');
|
||||
assertValid('https://thisvid.com/videos/%DIR%/', false, '/videos/%DIR%/');
|
||||
// a provider's pageContext.langUrls leaks must still be rejected.
|
||||
assertValid('https://example.com/playlists/', false, '/playlists/');
|
||||
assertValid('https://example.com/playlists/liked/', false, '/playlists/liked/');
|
||||
assertValid('https://example.com/playlist/%1%/video/%2%/', false, '/playlist/%1%/');
|
||||
assertValid('https://example.com/members/%ID%/playlists/', false, '/members/%ID%/playlists/');
|
||||
assertValid('https://example.com/videos/%DIR%/', false, '/videos/%DIR%/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HLS QA: scrapeTab isolated-world fallback (thisvid regression)', () => {
|
||||
describe('HLS QA: scrapeTab isolated-world fallback', () => {
|
||||
// Extract the whole scrapeTab function (balanced braces, indentation-agnostic).
|
||||
const scrapeSrc = extractFn(indexSource, 'scrapeTab');
|
||||
|
||||
|
||||
Generated
+2
-3
@@ -7,9 +7,6 @@
|
||||
"": {
|
||||
"name": "vidplex",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@rollup/rollup-darwin-arm64": "^4.60.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
@@ -1344,7 +1341,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
|
||||
@@ -307,12 +307,19 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
//
|
||||
// We cannot hardcode a target referer: the proxy is invoked from the viewer
|
||||
// (a different origin than the source page), and a DNR rule cannot read a live
|
||||
// page's origin. So we capture the referer that the REAL page uses for its video
|
||||
// requests — via chrome.webRequest.onBeforeSendHeaders, which sees the request
|
||||
// headers the browser already built — and persist it per CDN host. The
|
||||
// page's origin. So we derive the provider referer from the source tab's own URL.
|
||||
// For a cross-origin video request the browser sends the referring page's URL as
|
||||
// the Referer, and that page IS the tab currently loading the video — so the tab
|
||||
// URL is the referer we need. We read it via chrome.webRequest.onBeforeRequest
|
||||
// (no header access — it cannot read the request headers) plus chrome.tabs.get,
|
||||
// which returns the source tab's URL, and persist that per CDN host. The
|
||||
// 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.
|
||||
// This requires no knowledge of any particular site. Deriving the referer from
|
||||
// the tab URL (rather than reading the real request header via
|
||||
// onBeforeSendHeaders + extraHeaders) is intentional: extraInfoSpec header
|
||||
// parsing is rejected by some Chromium builds and fails the whole service worker,
|
||||
// and the tabs + host permissions already permit chrome.tabs.get.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -160,6 +160,17 @@ function m3u8DeepProbeFn() {
|
||||
|
||||
function walk(obj, path, depth) {
|
||||
if (depth > 5) return;
|
||||
// Cycle guard: the live window objects hold circular references
|
||||
// (window/_parent/_top/frames/_globalObject/_globalProxy are mutually
|
||||
// reachable), so a depth cap alone does NOT terminate — descending a cycle
|
||||
// re-walks the same identities along exponentially many paths. Each object
|
||||
// tree gets its own visited set so an identity is walked at most once per
|
||||
// tree (trees are independent, so the set is declared per call).
|
||||
const visited = new WeakSet();
|
||||
if (obj && typeof obj === 'object') {
|
||||
if (visited.has(obj)) return;
|
||||
visited.add(obj);
|
||||
}
|
||||
if (typeof obj === 'string') {
|
||||
const match = obj.match(/https?:\/\/[^"'`\s)]+?(?:\.m3u8|\.hlsvod|\/hls\/|\/playlist[^"'`\s]*|\/manifest[^"'`\s]*)[^"'`\s)]*/gi);
|
||||
if (match) {
|
||||
|
||||
Reference in new issue
Block a user