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:
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