diff --git a/frontend/js/player.js b/frontend/js/player.js index 6742843..a1b7897 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -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 diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 06d9830..7bff2ae 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -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); diff --git a/tests/unit_direct_probe.js b/tests/unit_direct_probe.js new file mode 100644 index 0000000..1538a8b --- /dev/null +++ b/tests/unit_direct_probe.js @@ -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); +});