mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
138 lines
6.4 KiB
JavaScript
138 lines
6.4 KiB
JavaScript
'use strict';
|
|
// SSRF guard for the media proxy. The proxy fetches a customer-supplied URL and re-serves it
|
|
// same-origin so a <canvas>/WebGL transition can read it. That makes it an open fetch primitive
|
|
// running on the box that also serves the dashboard — so it MUST NOT be reachable to internal /
|
|
// loopback / link-local / cloud-metadata targets. We therefore (1) allow only http/https, (2) DNS-
|
|
// resolve the host and reject if ANY resolved address is private/reserved (multi-A rebinding), and
|
|
// (3) return the vetted addresses so the caller PINS the socket to one of them — a re-resolve at
|
|
// connect time can't be rebound to 127.0.0.1/169.254.169.254 after we vetted it. Redirects are
|
|
// re-vetted the same way (the caller re-invokes assertSafeUrl on each hop).
|
|
|
|
const dns = require('dns').promises;
|
|
const net = require('net');
|
|
|
|
class SsrfError extends Error {
|
|
constructor(reason) { super('blocked: ' + reason); this.name = 'SsrfError'; this.reason = reason; }
|
|
}
|
|
|
|
// ---- IPv4 ----
|
|
function v4ToInt(ip) {
|
|
const p = ip.split('.');
|
|
if (p.length !== 4) return null;
|
|
let n = 0;
|
|
for (const part of p) {
|
|
const b = Number(part);
|
|
if (!Number.isInteger(b) || b < 0 || b > 255 || !/^\d{1,3}$/.test(part)) return null;
|
|
n = (n * 256) + b;
|
|
}
|
|
return n >>> 0;
|
|
}
|
|
function inV4(ip, cidr) {
|
|
const [base, bitsStr] = cidr.split('/');
|
|
const ipn = v4ToInt(ip), basen = v4ToInt(base);
|
|
if (ipn === null || basen === null) return false;
|
|
const bits = Number(bitsStr);
|
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
return (ipn & mask) === (basen & mask);
|
|
}
|
|
// 0.0.0.0/8 (this host), 10/8, 100.64/10 (CGNAT), 127/8 (loopback), 169.254/16 (link-local incl.
|
|
// cloud metadata 169.254.169.254), 172.16/12, 192.0.0/24, 192.0.2/24, 192.88.99/24, 192.168/16,
|
|
// 198.18/15, 198.51.100/24, 203.0.113/24, 224/4 (multicast), 240/4 (reserved/broadcast).
|
|
const V4_BLOCK = [
|
|
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
|
|
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
|
|
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
|
|
];
|
|
function isBlockedV4(ip) { return v4ToInt(ip) === null || V4_BLOCK.some((c) => inV4(ip, c)); }
|
|
|
|
// ---- IPv6 ----
|
|
// Expand any IPv6 text form (compressed ::, dotted-quad tail, hex) to 8 numeric hextets, or null.
|
|
function expandV6(ip) {
|
|
let s = ip.toLowerCase().replace(/^\[|\]$/g, '').split('%')[0]; // strip brackets / zone id
|
|
const dotted = s.match(/^(.*:)((?:\d{1,3}\.){3}\d{1,3})$/); // trailing embedded v4 -> 2 hextets
|
|
if (dotted) {
|
|
const v = dotted[2].split('.').map(Number);
|
|
if (v.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
s = dotted[1] + ((v[0] << 8) | v[1]).toString(16) + ':' + ((v[2] << 8) | v[3]).toString(16);
|
|
}
|
|
const halves = s.split('::');
|
|
if (halves.length > 2) return null;
|
|
const head = halves[0] ? halves[0].split(':') : [];
|
|
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : [];
|
|
let groups;
|
|
if (halves.length === 2) {
|
|
const fill = 8 - head.length - tail.length;
|
|
if (fill < 0) return null;
|
|
groups = head.concat(Array(fill).fill('0'), tail);
|
|
} else {
|
|
groups = head;
|
|
}
|
|
if (groups.length !== 8) return null;
|
|
const out = groups.map((g) => (g === '' ? NaN : parseInt(g, 16)));
|
|
if (out.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
|
|
return out;
|
|
}
|
|
function isBlockedV6(ip) {
|
|
const h = expandV6(ip);
|
|
if (!h) return true; // unparseable → block
|
|
// IPv4-mapped ::ffff:a.b.c.d and NAT64 64:ff9b::a.b.c.d → vet the embedded v4
|
|
if (h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && h[5] === 0xffff) {
|
|
return isBlockedV4([(h[6] >> 8) & 255, h[6] & 255, (h[7] >> 8) & 255, h[7] & 255].join('.'));
|
|
}
|
|
if (h[0] === 0x0064 && h[1] === 0xff9b) {
|
|
return isBlockedV4([(h[6] >> 8) & 255, h[6] & 255, (h[7] >> 8) & 255, h[7] & 255].join('.'));
|
|
}
|
|
if (h.every((x) => x === 0)) return true; // :: unspecified
|
|
if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true; // ::1 loopback
|
|
if ((h[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local
|
|
if ((h[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
if ((h[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
|
if (h[0] === 0x2002) return true; // 2002::/16 6to4
|
|
return false;
|
|
}
|
|
|
|
// A resolved address we must never let the proxy connect to.
|
|
function isBlockedIp(ip) {
|
|
const v = net.isIP(ip);
|
|
if (v === 4) return isBlockedV4(ip);
|
|
if (v === 6) return isBlockedV6(ip);
|
|
return true; // not a valid literal IP → block
|
|
}
|
|
|
|
// Parse + scheme-check + DNS-resolve + vet EVERY resolved address. Returns { url, addresses } where
|
|
// `addresses` are the vetted IPs to pin the socket to. Throws SsrfError on anything unsafe.
|
|
async function assertSafeUrl(urlString) {
|
|
let url;
|
|
try { url = new URL(String(urlString)); } catch (e) { throw new SsrfError('bad-url'); }
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new SsrfError('bad-scheme');
|
|
if (url.username || url.password) throw new SsrfError('userinfo'); // http://internal@evil.com tricks
|
|
|
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
// A literal IP in the URL still gets vetted (no DNS, but same range checks).
|
|
if (net.isIP(host)) {
|
|
if (isBlockedIp(host)) throw new SsrfError('blocked-ip:' + host);
|
|
return { url, addresses: [host] };
|
|
}
|
|
let resolved;
|
|
try { resolved = await dns.lookup(host, { all: true, verbatim: true }); }
|
|
catch (e) { throw new SsrfError('dns-fail'); }
|
|
if (!resolved.length) throw new SsrfError('no-address');
|
|
for (const a of resolved) {
|
|
if (isBlockedIp(a.address)) throw new SsrfError('blocked-ip:' + a.address);
|
|
}
|
|
return { url, addresses: resolved.map((a) => a.address) };
|
|
}
|
|
|
|
// Build a `lookup` for http.request that pins to a pre-vetted address, so the socket connects to the
|
|
// IP we checked — not a value a rebinding DNS server hands back a second time.
|
|
function pinnedLookup(vettedAddresses) {
|
|
const addr = vettedAddresses[0];
|
|
const family = net.isIP(addr);
|
|
return (hostname, options, cb) => {
|
|
if (typeof options === 'function') { cb = options; }
|
|
process.nextTick(() => cb(null, addr, family));
|
|
};
|
|
}
|
|
|
|
module.exports = { assertSafeUrl, isBlockedIp, isBlockedV4, isBlockedV6, pinnedLookup, SsrfError };
|