Authenticate browser media with durable HttpOnly login cookies

Adapted cookie, origin, URL validation and secret handling from MikaelKW/nodecast-tv-plus. Protect API roles, revoke logins across restart, preserve existing accounts, and isolate transcode sessions by account.

Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
Technomancer702
2026-10-12 16:53:15 -07:00
parent 2999d6542f
commit 1f665dcb11
24 changed files with 553 additions and 111 deletions

View File

@ -0,0 +1,16 @@
const { getDb } = require('../db/sqlite');
function store() {
const db = getDb();
db.exec('CREATE TABLE IF NOT EXISTS auth_revocations (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)');
db.exec('CREATE INDEX IF NOT EXISTS idx_auth_revocations_expiry ON auth_revocations(expires_at)');
db.prepare('DELETE FROM auth_revocations WHERE expires_at <= ?').run(Math.floor(Date.now() / 1000));
return db;
}
function isRevoked(payload) {
return Boolean(store().prepare('SELECT 1 FROM auth_revocations WHERE id = ?').get(payload.jti));
}
function revoke(payload) {
store().prepare('INSERT OR REPLACE INTO auth_revocations (id, expires_at) VALUES (?, ?)').run(payload.jti, payload.exp);
}
module.exports = { isRevoked, revoke };

View File

@ -1,5 +1,6 @@
const { Readable } = require('node:stream');
const { pipeline } = require('node:stream/promises');
const { validateHttpUrl } = require('./urlSecurity');
const MAX_HLS_MANIFEST_BYTES = 5 * 1024 * 1024;
@ -46,6 +47,8 @@ function rewriteManifest(manifest, sourceUrl, proxyPath) {
// Adapted from NodeCast TV Plus's streaming proxy. Binary media is never
// buffered in full; only manifests are collected, under a fixed size limit.
async function proxyStream(req, res) {
try { validateHttpUrl(req.query.url); }
catch (error) { return res.status(400).json({ error: error.message }); }
let url;
try {
const raw = req.query.url;

View File

@ -0,0 +1,19 @@
function requestLimit(maximum, windowMs = 60000) {
const buckets = new Map();
return (req, res, next) => {
const now = Date.now(), key = String(req.user?.id ?? req.ip);
for (const [id, bucket] of buckets) if (bucket.until <= now) buckets.delete(id);
let bucket = buckets.get(key);
if (!bucket) {
if (buckets.size >= 10000) return res.status(429).json({ error: 'Try again shortly' });
bucket = { count: 0, until: now + windowMs };
buckets.set(key, bucket);
}
if (++bucket.count > maximum) {
res.setHeader('Retry-After', Math.ceil((bucket.until - now) / 1000));
return res.status(429).json({ error: 'Too many requests; try again shortly' });
}
next();
};
}
module.exports = { requestLimit };

View File

@ -1,3 +1,4 @@
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('./urlSecurity');
/**
* Transcode Session Service
*
@ -57,6 +58,7 @@ class TranscodeSession extends EventEmitter {
super();
this.id = generateSessionId();
this.url = url;
this.ownerId = options.ownerId ?? null;
this.dir = path.join(CACHE_DIR, this.id);
this.playlistPath = path.join(this.dir, 'stream.m3u8');
this.process = null;
@ -89,7 +91,7 @@ class TranscodeSession extends EventEmitter {
}
this.status = 'starting';
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
console.log(`[TranscodeSession ${this.id}] Starting session for: ${redactUrl(this.url)}`);
// Create session directory
try {
@ -103,7 +105,7 @@ class TranscodeSession extends EventEmitter {
// Build FFmpeg arguments for HLS output
const args = this.buildFFmpegArgs();
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${redactText(args.join(' '))}`);
try {
this.process = spawn(this.options.ffmpegPath, args, {
@ -127,7 +129,7 @@ class TranscodeSession extends EventEmitter {
if (lines.length > 1) {
lines.slice(0, -1).forEach(line => {
if (line.trim()) {
console.log(`[FFmpeg ${this.id}] ${line}`);
console.log(`[FFmpeg ${this.id}] ${redactText(line)}`);
}
});
stderrBuffer = lines[lines.length - 1];
@ -150,7 +152,7 @@ class TranscodeSession extends EventEmitter {
// Handle spawn errors
this.process.on('error', (err) => {
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, redactText(err.message));
this.status = 'error';
this.error = err.message;
this.emit('error', err);
@ -184,6 +186,7 @@ class TranscodeSession extends EventEmitter {
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
'-user_agent', this.options.userAgent,
];
@ -661,6 +664,7 @@ class TranscodeSession extends EventEmitter {
* Create a new transcode session
*/
async function createSession(url, options = {}) {
validateHttpUrl(url);
await ensureCacheDir();
const session = new TranscodeSession(url, options);
sessions.set(session.id, session);
@ -670,8 +674,9 @@ async function createSession(url, options = {}) {
/**
* Get an existing session by ID
*/
function getSession(sessionId) {
function getSession(sessionId, ownerId = null) {
const session = sessions.get(sessionId);
if (session && ownerId !== null && session.ownerId !== ownerId) return null;
if (session) {
session.touch();
}
@ -682,26 +687,22 @@ function getSession(sessionId) {
* Get or create a session for a URL (reuses existing if still valid)
*/
async function getOrCreateSession(url, options = {}) {
// Check for existing session with same URL
for (const session of sessions.values()) {
if (session.url === url && session.status === 'running') {
session.touch();
return session;
}
}
// Create new session
// A URL cannot identify an account or its processing settings.
return createSession(url, options);
}
/**
* Stop and remove a session
*/
async function removeSession(sessionId) {
async function removeSession(sessionId, ownerId = null) {
const session = sessions.get(sessionId);
if (session && ownerId !== null && session.ownerId !== ownerId) return false;
if (session) {
await session.cleanup();
sessions.delete(sessionId);
return true;
}
return false;
}
/**
@ -760,7 +761,7 @@ function startCleanupInterval() {
function getAllSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
url: s.url,
url: redactUrl(s.url),
status: s.status,
startTime: s.startTime,
lastAccess: s.lastAccess,

View File

@ -0,0 +1,81 @@
'use strict';
const ALLOWED_MEDIA_PROTOCOLS = new Set(['http:', 'https:']);
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
const FFMPEG_PROTOCOL_WHITELIST = 'http,https,tcp,tls,crypto,hls';
function validationError(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function isSensitiveHost(hostname) {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
const allowLoopback = process.env.ALLOW_LOCAL_MEDIA_URLS === 'true';
if (host === 'metadata.google.internal') return true;
if (host === '169.254.169.254' || host.startsWith('169.254.')) return true;
const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mapped) {
const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16);
return isSensitiveHost(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
}
if ((!allowLoopback && host === '::1') || /^fe[89ab][0-9a-f]:/.test(host)) return true;
if (!allowLoopback && (host === 'localhost' || host.endsWith('.localhost'))) return true;
if (!allowLoopback && (/^127\./.test(host) || host === '0.0.0.0')) return true;
return false;
}
function validateHttpUrl(value, fieldName = 'URL') {
if (typeof value !== 'string' || !value.trim()) {
throw validationError(`${fieldName} is required`);
}
const candidate = value.trim();
if (CONTROL_CHARACTERS.test(value)) {
throw validationError(`${fieldName} contains invalid characters`);
}
let parsed;
try {
parsed = new URL(candidate);
} catch {
throw validationError(`${fieldName} must be a valid HTTP or HTTPS URL`);
}
if (!ALLOWED_MEDIA_PROTOCOLS.has(parsed.protocol)) {
throw validationError(`${fieldName} must use HTTP or HTTPS`);
}
if (!parsed.hostname) {
throw validationError(`${fieldName} must include a host`);
}
if (isSensitiveHost(parsed.hostname)) {
throw validationError(`${fieldName} points to a protected local or metadata address`);
}
return candidate;
}
function redactUrl(value) {
try {
const parsed = new URL(value);
if (!ALLOWED_MEDIA_PROTOCOLS.has(parsed.protocol)) return '[unsupported URL]';
return `${parsed.protocol}//${parsed.host}/…`;
} catch {
return '[invalid URL]';
}
}
function redactText(value) {
return String(value ?? '').replace(/https?:\/\/[^\s"'<>]+/gi, '[redacted URL]');
}
module.exports = {
FFMPEG_PROTOCOL_WHITELIST,
redactText,
redactUrl,
validateHttpUrl
};