Check a direct route hands back the video it was asked for

The proxy exists to get around CORS, hotlink protection and TLS
fingerprinting, so the player probes whether a CDN will serve the browser
itself and skips the hop when it will. The probe took a readable status
line and some bytes as proof.

Not every hotlink guard refuses a foreign Referer with a 403. eporner's
CDN answers one with a 302 to a one-minute "watch this at our site
instead" clip, served 206 with Access-Control-Allow-Origin: *. fetch()
follows that redirect without comment, so the probe read the stand-in as
a win, cached the host as direct, and the player restarted playback on
the raw URL -- where the browser sent the page's own Referer again and
got the stand-in a second time. An eporner video played as 61 seconds of
advertisement instead of itself.

Only the resolved path was affected: yt-dlp's eporner formats carry no
Referer, so refererRequired was false and the race ran. The listing's own
formats name one and were always proxied.

So ask the proxy, which carries the Referer the origin wants, how long
the media is, and refuse a direct route that reports another length. The
question goes out alongside the direct fetch rather than after it, and an
unknown length on either side leaves the old bytes test standing, so
chunked responses and playlists decide as before. No Range header: that
would cost a CORS preflight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-10-22 20:58:45 +00:00
parent 4f772773e1
commit 7fc718158a
3 changed files with 258 additions and 3 deletions

View File

@@ -830,7 +830,7 @@ App.player = App.player || {};
// a browser, so there is nothing to find out.
if (resolved.refererRequired) return;
if (directProven(resolved.url)) return;
App.videos.probeDirect(resolved.url).then((ok) => {
App.videos.probeDirect(resolved).then((ok) => {
if (!ok || token !== cp.attemptToken) return;
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
// playing video alone rather than trading a visible stall for a

View File

@@ -2455,12 +2455,48 @@ App.videos = App.videos || {};
// same for every media URL served by a given host: we probe (and cache)
// once per host and let the player skip the proxy for any URL on a host
// that's been proven.
//
// Readable bytes alone don't settle it. Not every hotlink guard answers a
// foreign Referer with a 403: eporner's CDN answers one with a 302 to a
// one-minute "watch this at our site instead" clip, served 206 with
// `Access-Control-Allow-Origin: *`. fetch() follows that redirect without
// comment, so it reads as a win and plays as a different video. The probe
// therefore also checks it was handed *this* resource, by asking the proxy
// -- which carries the Referer the origin wants -- how long the media is.
// A direct route reporting some other length is a substitution, not a
// shortcut, and belongs on the proxy.
const DIRECT_PROBE_TIMEOUT_MS = 8000;
const directHostOf = (url) => {
try { return new URL(url).host; } catch (err) { return ''; }
};
// The total size of the media a response describes, or 0 when it won't say.
// An un-ranged GET states it in Content-Length, which CORS exposes to
// script without the origin having to opt in; a ranged one puts it after
// the slash in Content-Range.
const totalLengthOf = function(res) {
const match = /\/\s*(\d+)\s*$/.exec(res.headers.get('Content-Range') || '');
if (match) return parseInt(match[1], 10) || 0;
return parseInt(res.headers.get('Content-Length') || '0', 10) || 0;
};
// How long the proxied route says this media is, or 0 if it won't say. The
// proxy answers a HEAD with the whole resource's length (see stream_video's
// head_probe) and fetches with the headers the extractor attached, so this
// is the length of the media the viewer is actually meant to get.
const proxiedLength = async function(resolved, signal) {
try {
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
if (!streamUrl) return 0;
const res = await fetch(streamUrl, { method: 'HEAD', signal: signal });
if (!(res.ok || res.status === 206)) return 0;
return totalLengthOf(res);
} catch (err) {
return 0;
}
};
// host -> true (proven directly playable) | false (proven not). Absent
// means unknown/unprobed, in which case the proxy is used.
App.videos._directStatus = new Map();
@@ -2470,7 +2506,12 @@ App.videos = App.videos || {};
return App.videos._directStatus.get(directHostOf(url)) === true;
};
App.videos.probeDirect = function(url) {
// Takes a resolved source (as resolveStreamSources builds them) so the
// proxied cross-check can be addressed with the same headers playback uses.
// A bare URL string still works; it just can't be cross-checked as closely.
App.videos.probeDirect = function(source) {
const resolved = (typeof source === 'string') ? { url: source } : (source || {});
const url = resolved.url;
if (!url) return Promise.resolve(false);
const host = directHostOf(url);
if (!host) return Promise.resolve(false);
@@ -2482,7 +2523,17 @@ App.videos = App.videos || {};
}
const promise = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DIRECT_PROBE_TIMEOUT_MS);
// The cross-check gets its own controller: the direct fetch aborts
// as soon as it has its sample, and that must not cancel a HEAD the
// comparison still needs. One timer bounds them both.
const headController = new AbortController();
const timer = setTimeout(() => {
controller.abort();
headController.abort();
}, DIRECT_PROBE_TIMEOUT_MS);
// Asked alongside the direct fetch rather than after it: the probe
// runs beside live playback and shouldn't cost two serial trips.
const expectedLength = proxiedLength(resolved, headController.signal);
let ok = false;
let detail = '';
try {
@@ -2515,6 +2566,18 @@ App.videos = App.videos || {};
ok = true;
detail = `HTTP ${res.status}, headers only`;
}
if (ok) {
// The same media, or a stand-in? Only decided when both
// sides state a length: a chunked response or a playlist
// states none, and an unknown length is no evidence of
// substitution.
const direct = totalLengthOf(res);
const expected = await expectedLength;
if (direct && expected && direct !== expected) {
ok = false;
detail += `, ${direct} bytes but proxied is ${expected} -- substituted`;
}
}
controller.abort();
} catch (err) {
// A CORS refusal lands here as a TypeError with no status --
@@ -2524,6 +2587,10 @@ App.videos = App.videos || {};
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)';
} finally {
clearTimeout(timer);
// A refused direct fetch never waits on the comparison, so drop
// the HEAD rather than leaving it to run for an answer nobody
// will read. Harmless once it has already been awaited.
headController.abort();
}
App.videos._directStatus.set(host, ok);
directPending.delete(host);

188
tests/unit_direct_probe.js Normal file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env node
/* The direct-playability probe, tested without a browser.
*
* node tests/unit_direct_probe.js
*
* The probe decides whether the player may skip the proxy and let the browser
* fetch a CDN URL itself. Getting that wrong is not a slow video but a wrong
* one: eporner's CDN answers a browser-origin Referer with a 302 to a
* one-minute "watch this at our site instead" clip, served 206 with
* `Access-Control-Allow-Origin: *`, so "bytes arrived" was satisfied by a
* stand-in and the player switched to it mid-video. What settles it is whether
* the direct route hands back the same media the proxied one does, which is
* two stubbed fetches to check -- no server, no Chromium, no network.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const noop = () => {};
const element = () => ({
style: { setProperty: noop, removeProperty: noop },
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
dataset: {},
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: noop,
removeEventListener: noop,
appendChild: noop,
removeChild: noop,
remove: noop,
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
setAttribute: noop,
removeAttribute: noop,
getAttribute: () => null,
cloneNode: element,
content: { firstElementChild: { cloneNode: element } },
children: [],
childElementCount: 0,
});
const sandbox = {
console,
setTimeout,
clearTimeout,
URL,
Response,
AbortController,
Image: function () { return element(); },
IntersectionObserver: function () {
return { observe: noop, unobserve: noop, disconnect: noop };
},
requestAnimationFrame: noop,
requestIdleCallback: noop,
performance: { now: () => 0 },
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
};
sandbox.addEventListener = noop;
sandbox.removeEventListener = noop;
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
sandbox.document = {
getElementById: () => null,
createElement: element,
querySelectorAll: () => [],
addEventListener: noop,
documentElement: element(),
body: element(),
head: element(),
};
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
sandbox.window.location = { href: 'http://localhost/' };
const context = vm.createContext(sandbox);
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
vm.runInContext(
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', 'videos.js'), 'utf8'),
context, 'videos.js');
const { probeDirect } = sandbox.App.videos;
let failed = 0;
const ok = (label, cond, detail) => {
if (!cond) failed++;
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
};
// Stands in for both routes. `direct` is what the CDN hands the browser,
// `proxied` what /api/stream reports -- either given as a byte count, or null
// for a response that states no length at all. Every call is recorded so the
// test can check what was asked of whom.
let calls = [];
const stubFetch = function(direct, proxied) {
calls = [];
sandbox.fetch = async (url, opts) => {
calls.push({ url: String(url), method: (opts && opts.method) || 'GET' });
if (String(url).indexOf('/api/stream') === 0) {
const headers = proxied === null ? {} : { 'Content-Length': String(proxied) };
return new Response('', { status: 200, headers: headers });
}
const headers = direct === null ? {} : { 'Content-Length': String(direct) };
return new Response(new Uint8Array(16), { status: 206, headers: headers });
};
};
// A fresh host each time: the probe caches its answer per host for the session,
// which is the point of it, so reusing one would answer from the cache.
let hostSeq = 0;
const sourceOn = (host) => ({
url: `https://${host}/v4/token/10299874-1080p.mp4`,
referer: 'https://www.eporner.com/',
headers: { Referer: 'https://www.eporner.com/' },
});
const freshSource = () => sourceOn(`cdn${++hostSeq}.example.com`);
const run = async () => {
console.log('\nwhat the two routes say about the same URL');
stubFetch(486933813, 486933813);
ok('the same length both ways is a shortcut', await probeDirect(freshSource()) === true);
// The bug: 642497 is static.eporner.com/na.mp4, the stand-in clip.
stubFetch(642497, 486933813);
ok('a shorter file than the proxy reports is a substitution',
await probeDirect(freshSource()) === false);
// Not every mismatch is a decoy -- a longer one is just as wrong.
stubFetch(486933813, 642497);
ok('any disagreement about length is refused',
await probeDirect(freshSource()) === false);
console.log('\nwhen a length cannot be had');
stubFetch(642497, null);
ok('a proxy that states no length leaves the bytes test standing',
await probeDirect(freshSource()) === true);
stubFetch(null, 486933813);
ok('a direct route that states no length is not accused',
await probeDirect(freshSource()) === true);
stubFetch(null, null);
ok('neither stating one still plays direct',
await probeDirect(freshSource()) === true);
console.log('\nhow the cross-check is addressed');
stubFetch(486933813, 486933813);
const src = freshSource();
await probeDirect(src);
const head = calls.find((c) => c.url.indexOf('/api/stream') === 0);
ok('the proxy is asked, by HEAD', !!head && head.method === 'HEAD',
head ? head.method : 'never asked');
ok('with the Referer the origin wants, not the page\'s',
!!head && head.url.indexOf(`referer=${encodeURIComponent('https://www.eporner.com/')}`) !== -1,
head ? head.url : '');
ok('about the very URL being probed',
!!head && head.url.indexOf(encodeURIComponent(src.url)) !== -1);
console.log('\nthe answer is kept per host');
stubFetch(486933813, 486933813);
const repeat = sourceOn('cached.example.com');
await probeDirect(repeat);
const afterFirst = calls.length;
await probeDirect(repeat);
ok('a second probe of a known host asks nobody', calls.length === afterFirst,
`${calls.length - afterFirst} extra call(s)`);
console.log('\na bare URL still works');
stubFetch(642497, 486933813);
ok('a string is probed, and still cross-checked',
await probeDirect('https://bare.example.com/v.mp4') === false);
stubFetch(486933813, 486933813);
ok('nothing to probe is not playable', await probeDirect('') === false);
ok('neither is a source without a URL', await probeDirect({ url: '' }) === false);
};
run().then(() => {
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
process.exit(failed ? 1 : 0);
}).catch((err) => {
console.error(err);
process.exit(1);
});