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>
82 lines
2.5 KiB
JavaScript
82 lines
2.5 KiB
JavaScript
'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
|
|
};
|