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:
@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken');
|
||||
const passport = require('passport');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { Strategy: LocalStrategy } = require('passport-local');
|
||||
const security = require('./config/security');
|
||||
const authTokens = require('./services/authTokens');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
/**
|
||||
* Authentication and Authorization Module
|
||||
@ -11,7 +14,7 @@ const { Strategy: LocalStrategy } = require('passport-local');
|
||||
*/
|
||||
|
||||
// JWT Secret - In production, use environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
|
||||
const JWT_SECRET = security.jwtSecret;
|
||||
const JWT_EXPIRY = '24h';
|
||||
|
||||
/**
|
||||
@ -26,6 +29,7 @@ async function hashPassword(password) {
|
||||
* Verify password against hash
|
||||
*/
|
||||
async function verifyPassword(password, hash) {
|
||||
if (typeof password !== 'string' || typeof hash !== 'string') return false;
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
@ -37,10 +41,12 @@ function generateToken(user) {
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
role: user.role,
|
||||
authVersion: user.authVersion || 0,
|
||||
kind: 'access'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRY }
|
||||
{ expiresIn: JWT_EXPIRY, algorithm: 'HS256', audience: 'nodecast-tv', jwtid: crypto.randomUUID() }
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,7 +55,8 @@ function generateToken(user) {
|
||||
*/
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'], audience: 'nodecast-tv' });
|
||||
return payload.kind === 'access' && typeof payload.jti === 'string' ? payload : null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
@ -87,15 +94,18 @@ function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
|
||||
*/
|
||||
function configureJwtStrategy(getUserById) {
|
||||
const options = {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: JWT_SECRET
|
||||
jwtFromRequest: extractToken,
|
||||
secretOrKey: JWT_SECRET,
|
||||
algorithms: ['HS256'],
|
||||
audience: 'nodecast-tv'
|
||||
};
|
||||
|
||||
passport.use(new JwtStrategy(options, async (payload, done) => {
|
||||
try {
|
||||
const user = await getUserById(payload.id);
|
||||
|
||||
if (!user) {
|
||||
if (!user || payload.kind !== 'access' || typeof payload.jti !== 'string'
|
||||
|| (payload.authVersion || 0) !== (user.authVersion || 0) || authTokens.isRevoked(payload)) {
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
@ -195,7 +205,7 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
||||
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
|
||||
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
|
||||
|
||||
if (!user && email) {
|
||||
if (!user && email && profile?._json?.email_verified === true) {
|
||||
user = await findUserByEmail(email);
|
||||
|
||||
// If found by email but no OIDC ID, link them
|
||||
@ -228,7 +238,50 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
||||
/**
|
||||
* Middleware: Require authentication using Passport JWT
|
||||
*/
|
||||
const requireAuth = passport.authenticate('jwt', { session: false });
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.authenticatedUser) { req.user = req.authenticatedUser; return next(); }
|
||||
passport.authenticate('jwt', { session: false }, (error, user) => {
|
||||
if (error) return next(error);
|
||||
if (!user) return res.status(401).json({ error: 'Authentication required' });
|
||||
req.user = req.authenticatedUser = user;
|
||||
next();
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
function extractToken(req) {
|
||||
const bearer = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
if (bearer) return bearer;
|
||||
for (const part of (req.headers.cookie || '').split(';')) {
|
||||
const separator = part.indexOf('=');
|
||||
if (part.slice(0, separator).trim() === security.authCookieName) {
|
||||
try { return decodeURIComponent(part.slice(separator + 1)); } catch { return null; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cookieOptions(req) {
|
||||
return { httpOnly: true, sameSite: 'lax', secure: process.env.AUTH_COOKIE_SECURE === 'true'
|
||||
|| (process.env.AUTH_COOKIE_SECURE !== 'false' && Boolean(req.secure)), path: '/' };
|
||||
}
|
||||
function setAuthCookie(req, res, token) {
|
||||
res.cookie(security.authCookieName, token, { ...cookieOptions(req), maxAge: 24 * 60 * 60 * 1000 });
|
||||
}
|
||||
function clearAuthCookie(req, res) {
|
||||
res.clearCookie(security.authCookieName, cookieOptions(req));
|
||||
}
|
||||
function requireSameOrigin(req, res, next) {
|
||||
if (req.path.startsWith('/auth/oidc/')) return next();
|
||||
if (req.get('sec-fetch-site') === 'cross-site') return res.status(403).json({ error: 'Cross-site request blocked' });
|
||||
const origin = req.get('origin') || (!['GET', 'HEAD', 'OPTIONS'].includes(req.method) ? req.get('referer') : null);
|
||||
if (!origin) return next(); // Non-browser API clients still require authentication.
|
||||
try {
|
||||
const allowed = process.env.APP_ORIGIN ? new URL(process.env.APP_ORIGIN).origin
|
||||
: new URL(`${req.protocol}://${req.get('host')}`).origin;
|
||||
if (new URL(origin).origin === allowed) return next();
|
||||
} catch { /* Invalid origins are rejected. */ }
|
||||
res.status(403).json({ error: 'Cross-site request blocked' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require admin role
|
||||
@ -263,6 +316,10 @@ module.exports = {
|
||||
configureSessionSerialization,
|
||||
configureOidcStrategy,
|
||||
requireAuth,
|
||||
extractToken,
|
||||
setAuthCookie,
|
||||
clearAuthCookie,
|
||||
requireSameOrigin,
|
||||
requireAdmin,
|
||||
requireRole
|
||||
};
|
||||
|
||||
21
server/config/security.js
Normal file
21
server/config/security.js
Normal file
@ -0,0 +1,21 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const dataDir = process.env.NODECAST_DATA_DIR ? path.resolve(process.env.NODECAST_DATA_DIR) : path.join(__dirname, '../../data');
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const secretPath = path.join(dataDir, 'security.json');
|
||||
try {
|
||||
fs.writeFileSync(secretPath, JSON.stringify({ jwt: crypto.randomBytes(32).toString('base64url'),
|
||||
session: crypto.randomBytes(32).toString('base64url') }), { flag: 'wx', mode: 0o600 });
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
}
|
||||
const persisted = JSON.parse(fs.readFileSync(secretPath, 'utf8'));
|
||||
if (typeof persisted.jwt !== 'string' || persisted.jwt.length < 32 || typeof persisted.session !== 'string' || persisted.session.length < 32) {
|
||||
throw new Error('Invalid persisted authentication secrets');
|
||||
}
|
||||
const jwtSecret = process.env.JWT_SECRET || persisted.jwt;
|
||||
const suffix = crypto.createHash('sha256').update(jwtSecret).digest('hex').slice(0, 12);
|
||||
module.exports = { jwtSecret, sessionSecret: process.env.SESSION_SECRET || persisted.session,
|
||||
authCookieName: `nodecast_auth_${suffix}`, sessionCookieName: `nodecast_session_${suffix}` };
|
||||
@ -428,13 +428,14 @@ const users = {
|
||||
return db.users?.find(u => u.email === email);
|
||||
},
|
||||
|
||||
async create(userData) {
|
||||
async create(userData, { initialSetup = false } = {}) {
|
||||
return mutateDb((db, markChanged) => {
|
||||
if (!db.users) {
|
||||
db.users = [];
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
if (initialSetup && db.users.length) throw new Error('Setup already completed');
|
||||
if (db.users.some(u => u.username === userData.username)) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
@ -483,6 +484,7 @@ const users = {
|
||||
db.users[userIndex] = {
|
||||
...db.users[userIndex],
|
||||
...updates,
|
||||
authVersion: updates.passwordHash ? (db.users[userIndex].authVersion || 0) + 1 : (db.users[userIndex].authVersion || 0),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
@ -2,6 +2,10 @@ const express = require('express');
|
||||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const passport = require('passport');
|
||||
const auth = require('./auth');
|
||||
const security = require('./config/security');
|
||||
const { requestLimit } = require('./services/requestLimits');
|
||||
const { validateHttpUrl, redactText } = require('./services/urlSecurity');
|
||||
const syncService = require('./services/syncService');
|
||||
|
||||
// Initialize database
|
||||
@ -13,7 +17,8 @@ const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For, etc.)
|
||||
// Required for correct protocol detection behind reverse proxies (nginx, Caddy, etc.)
|
||||
app.set('trust proxy', true);
|
||||
const trustProxy = process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustProxy === 'true' ? true : trustProxy === 'false' ? false : trustProxy || 'loopback, linklocal, uniquelocal');
|
||||
|
||||
// Middleware
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
@ -21,9 +26,11 @@ app.use(express.json({ limit: '50mb' }));
|
||||
// Initialize Passport
|
||||
const session = require('express-session');
|
||||
app.use(session({
|
||||
secret: process.env.JWT_SECRET || 'keyboard cat',
|
||||
name: security.sessionCookieName,
|
||||
secret: security.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: true
|
||||
saveUninitialized: false,
|
||||
cookie: { httpOnly: true, sameSite: 'lax', secure: 'auto', maxAge: 24 * 60 * 60 * 1000 }
|
||||
}));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
@ -172,7 +179,25 @@ process.on('SIGTERM', async () => {
|
||||
|
||||
// API Routes
|
||||
app.get('/api/health', require('./services/health'));
|
||||
app.get('/api/version', (req, res) => res.json({ version: require('../package.json').version }));
|
||||
app.use('/api', auth.requireSameOrigin);
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api', auth.requireAuth);
|
||||
app.use('/api', (req, res, next) => {
|
||||
try {
|
||||
if (req.query.url !== undefined) validateHttpUrl(req.query.url);
|
||||
if (req.body?.url !== undefined) validateHttpUrl(req.body.url);
|
||||
next();
|
||||
} catch (error) { res.status(400).json({ error: error.message }); }
|
||||
});
|
||||
app.use('/api', (req, res, next) => {
|
||||
const adminAction = !['GET', 'HEAD', 'OPTIONS'].includes(req.method)
|
||||
&& /^\/(sources|channels|settings|proxy\/cache)(\/|$)/.test(req.path);
|
||||
if (adminAction) return auth.requireAdmin(req, res, next);
|
||||
next();
|
||||
});
|
||||
const mediaLimit = requestLimit(60);
|
||||
app.use(['/api/remux', '/api/probe', '/api/subtitle'], mediaLimit);
|
||||
app.use('/api/sources', require('./routes/sources'));
|
||||
app.use('/api/proxy', require('./routes/proxy'));
|
||||
app.use('/api/channels', require('./routes/channels'));
|
||||
@ -184,12 +209,6 @@ app.use('/api/subtitle', require('./routes/subtitle'));
|
||||
app.use('/api/settings', require('./routes/settings'));
|
||||
app.use('/api/history', require('./routes/history'));
|
||||
|
||||
// Version endpoint
|
||||
app.get('/api/version', (req, res) => {
|
||||
const pkg = require('../package.json');
|
||||
res.json({ version: pkg.version });
|
||||
});
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||
@ -197,18 +216,19 @@ app.get('*', (req, res) => {
|
||||
|
||||
// Error handling
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Server error:', err);
|
||||
console.error('Server error:', redactText(err.message));
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`NodeCast TV server running on http://localhost:${PORT}`);
|
||||
const server = app.listen(PORT, async () => {
|
||||
console.log(`NodeCast TV server running on http://localhost:${server.address().port}`);
|
||||
|
||||
// Load plugins
|
||||
await loadPlugins().catch(err => {
|
||||
console.error('Plugin initialization failed:', err);
|
||||
});
|
||||
app.locals.ready = true;
|
||||
if (process.env.NODECAST_DISABLE_BACKGROUND_JOBS === 'true') return;
|
||||
|
||||
// Trigger background sync with delay to allow server to settle
|
||||
setTimeout(async () => {
|
||||
|
||||
@ -2,6 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const auth = require('../auth');
|
||||
const { requestLimit } = require('../services/requestLimits');
|
||||
const authTokens = require('../services/authTokens');
|
||||
const loginLimit = requestLimit(20);
|
||||
|
||||
// Configure Passport strategies
|
||||
auth.configureLocalStrategy(
|
||||
@ -41,8 +44,8 @@ router.get('/oidc/callback',
|
||||
// Successful authentication
|
||||
const token = auth.generateToken(req.user);
|
||||
|
||||
// Redirect to hompage with token
|
||||
res.redirect(`/?token=${token}`);
|
||||
auth.setAuthCookie(req, res, token);
|
||||
req.session.regenerate(error => error ? res.status(500).json({ error: 'Login failed' }) : res.redirect('/'));
|
||||
}
|
||||
);
|
||||
|
||||
@ -52,7 +55,7 @@ router.get('/oidc/callback',
|
||||
*/
|
||||
router.get('/setup-required', async (req, res) => {
|
||||
try {
|
||||
const userCount = await db.users.count();
|
||||
const userCount = (await db.checkHealth()).users.length;
|
||||
res.json({ setupRequired: userCount === 0 });
|
||||
} catch (err) {
|
||||
console.error('Error in /setup-required:', err);
|
||||
@ -64,9 +67,9 @@ router.get('/setup-required', async (req, res) => {
|
||||
* Initial setup - Create admin user
|
||||
* POST /api/auth/setup
|
||||
*/
|
||||
router.post('/setup', async (req, res) => {
|
||||
router.post('/setup', loginLimit, async (req, res) => {
|
||||
try {
|
||||
const userCount = await db.users.count();
|
||||
const userCount = (await db.checkHealth()).users.length;
|
||||
|
||||
// Check if setup already done
|
||||
if (userCount > 0) {
|
||||
@ -75,11 +78,11 @@ router.post('/setup', async (req, res) => {
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
if (typeof username !== 'string' || !username.trim() || username.length > 64 || typeof password !== 'string') {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
if (password.length < 6 || Buffer.byteLength(password) > 72) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
@ -89,10 +92,11 @@ router.post('/setup', async (req, res) => {
|
||||
username,
|
||||
passwordHash,
|
||||
role: 'admin'
|
||||
});
|
||||
}, { initialSetup: true });
|
||||
|
||||
// Generate token for immediate login
|
||||
const token = auth.generateToken(adminUser);
|
||||
auth.setAuthCookie(req, res, token);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Admin user created successfully',
|
||||
@ -109,7 +113,10 @@ router.post('/setup', async (req, res) => {
|
||||
* Login with Passport Local Strategy
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', (req, res, next) => {
|
||||
router.post('/login', loginLimit, (req, res, next) => {
|
||||
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string' || req.body.password.length > 1024) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
auth.passport.authenticate('local', { session: false }, (err, user, info) => {
|
||||
if (err) {
|
||||
console.error('Login error:', err);
|
||||
@ -122,6 +129,7 @@ router.post('/login', (req, res, next) => {
|
||||
|
||||
// Generate JWT token
|
||||
const token = auth.generateToken(user);
|
||||
auth.setAuthCookie(req, res, token);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
@ -139,11 +147,19 @@ router.post('/login', (req, res, next) => {
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
// With JWT, logout is handled client-side by removing the token
|
||||
// This endpoint exists for consistency and future server-side token blacklisting
|
||||
const payload = auth.verifyToken(auth.extractToken(req));
|
||||
if (payload) authTokens.revoke(payload);
|
||||
auth.clearAuthCookie(req, res);
|
||||
if (req.session) req.session.destroy(() => {});
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
// Exchange a valid API token for a browser cookie during migration.
|
||||
router.post('/session', auth.requireAuth, (req, res) => {
|
||||
auth.setAuthCookie(req, res, auth.extractToken(req));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
* GET /api/auth/me
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
@ -41,6 +42,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||
'-print_format', 'json',
|
||||
'-show_streams',
|
||||
'-show_format',
|
||||
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
|
||||
'-probesize', '5000000',
|
||||
'-analyzeduration', '5000000',
|
||||
url
|
||||
@ -150,6 +152,7 @@ function analyzeProbeResult(probeResult, url) {
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { url, ua } = req.query;
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
@ -173,11 +176,11 @@ router.get('/', async (req, res) => {
|
||||
// Check cache
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
||||
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
||||
console.log(`[Probe] Cache hit for: ${redactUrl(url)}...`);
|
||||
return res.json(cached.result);
|
||||
}
|
||||
|
||||
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||
console.log(`[Probe] Probing: ${redactUrl(url)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||
|
||||
try {
|
||||
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { validateHttpUrl } = require('../services/urlSecurity');
|
||||
router.use((req, res, next) => {
|
||||
if (req.query.url !== undefined) {
|
||||
try { validateHttpUrl(req.query.url); }
|
||||
catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
}
|
||||
next();
|
||||
});
|
||||
const { sources } = require('../db');
|
||||
const { getDb } = require('../db/sqlite'); // Import SQLite
|
||||
const xtreamApi = require('../services/xtreamApi');
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
const { redactUrl, redactText, validateHttpUrl } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
@ -62,6 +63,7 @@ function validateStreamUrl(raw) {
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
@ -84,7 +86,7 @@ router.get('/', async (req, res) => {
|
||||
const settings = await db.settings.get();
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
console.log(`[Remux] Starting remux for: ${safeUrl}`);
|
||||
console.log(`[Remux] Starting remux for: ${redactUrl(safeUrl)}`);
|
||||
console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`);
|
||||
|
||||
// FFmpeg arguments for pure remux (no encoding)
|
||||
@ -137,13 +139,13 @@ router.get('/', async (req, res) => {
|
||||
'-' // Output to stdout
|
||||
];
|
||||
|
||||
console.log(`[Remux] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
console.log(`[Remux] Full command: ${ffmpegPath} ${redactText(args.join(' '))}`);
|
||||
|
||||
let ffmpeg;
|
||||
try {
|
||||
ffmpeg = spawn(ffmpegPath, args);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', spawnErr);
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', redactText(spawnErr.message));
|
||||
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||
}
|
||||
|
||||
@ -159,7 +161,7 @@ router.get('/', async (req, res) => {
|
||||
const msg = data.toString();
|
||||
// Only log warnings/errors, not progress
|
||||
if (msg.includes('Warning') || msg.includes('Error') || msg.includes('error')) {
|
||||
console.log(`[Remux FFmpeg] ${msg}`);
|
||||
console.log(`[Remux FFmpeg] ${redactText(msg)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -178,7 +180,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// Handle spawn errors
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', err);
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', redactText(err.message));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Remux failed to start' });
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const db = require('../db');
|
||||
const { requireAdmin } = require('../auth');
|
||||
const transcodeSession = require('../services/transcodeSession');
|
||||
|
||||
/**
|
||||
@ -35,12 +37,14 @@ router.post('/session', async (req, res) => {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
||||
const settings = await db.settings.get();
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
try {
|
||||
const session = await transcodeSession.createSession(url, {
|
||||
ownerId: req.user.id,
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
@ -76,7 +80,7 @@ router.post('/session', async (req, res) => {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Transcode] Session creation failed:', err);
|
||||
console.error('[Transcode] Session creation failed:', redactText(err.message));
|
||||
res.status(500).json({ error: 'Failed to create session', details: err.message });
|
||||
}
|
||||
});
|
||||
@ -87,7 +91,7 @@ router.post('/session', async (req, res) => {
|
||||
*/
|
||||
router.get('/:sessionId/stream.m3u8', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = transcodeSession.getSession(sessionId);
|
||||
const session = transcodeSession.getSession(sessionId, req.user.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
@ -115,7 +119,7 @@ router.get('/:sessionId/:segment', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Invalid segment' });
|
||||
}
|
||||
|
||||
const session = transcodeSession.getSession(sessionId);
|
||||
const session = transcodeSession.getSession(sessionId, req.user.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
@ -138,7 +142,8 @@ router.delete('/:sessionId', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
await transcodeSession.removeSession(sessionId);
|
||||
if (!transcodeSession.getSession(sessionId, req.user.id)) return res.status(404).json({ error: 'Session not found' });
|
||||
await transcodeSession.removeSession(sessionId, req.user.id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
||||
@ -149,7 +154,7 @@ router.delete('/:sessionId', async (req, res) => {
|
||||
* List all active sessions (for debugging)
|
||||
* GET /api/transcode/sessions
|
||||
*/
|
||||
router.get('/sessions', (req, res) => {
|
||||
router.get('/sessions', requireAdmin, (req, res) => {
|
||||
res.json(transcodeSession.getAllSessions());
|
||||
});
|
||||
|
||||
@ -162,6 +167,7 @@ router.get('/sessions', (req, res) => {
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
@ -172,7 +178,7 @@ router.get('/', async (req, res) => {
|
||||
const settings = await db.settings.get();
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
console.log(`[Transcode] Starting transcoding for: ${url}`);
|
||||
console.log(`[Transcode] Starting transcoding for: ${redactUrl(url)}`);
|
||||
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||
|
||||
@ -182,6 +188,7 @@ router.get('/', async (req, res) => {
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
|
||||
'-user_agent', userAgent,
|
||||
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||
@ -223,13 +230,13 @@ router.get('/', async (req, res) => {
|
||||
'-' // Output to stdout
|
||||
];
|
||||
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${redactText(args.join(' '))}`);
|
||||
|
||||
let ffmpeg;
|
||||
try {
|
||||
ffmpeg = spawn(ffmpegPath, args);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', redactText(spawnErr.message));
|
||||
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||
}
|
||||
|
||||
@ -247,7 +254,7 @@ router.get('/', async (req, res) => {
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
stderrBuffer += msg;
|
||||
console.log(`[FFmpeg] ${msg}`);
|
||||
console.log(`[FFmpeg] ${redactText(msg)}`);
|
||||
});
|
||||
|
||||
// Cleanup on client disconnect
|
||||
@ -265,7 +272,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// Handle spawn errors
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', err);
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', redactText(err.message));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Transcoding failed to start' });
|
||||
}
|
||||
|
||||
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