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:
16
server/services/authTokens.js
Normal file
16
server/services/authTokens.js
Normal 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 };
|
||||
@ -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;
|
||||
|
||||
19
server/services/requestLimits.js
Normal file
19
server/services/requestLimits.js
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
81
server/services/urlSecurity.js
Normal file
81
server/services/urlSecurity.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user