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);