diff --git a/README.md b/README.md index 19dfcdf..f16bce7 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,16 @@ nodecast-tv/ └── data/ # Persistent storage (content.db, playlists) ``` +## Authentication and reverse proxies + +Browser playback and API requests use an HttpOnly authentication cookie. API clients can also send the token returned by `/api/auth/login` as `Authorization: Bearer `. Existing accounts and settings are retained, but clients must sign in again after upgrading from the older token format. Logout revokes that login; changing a password invalidates all of that account's existing tokens. + +Random authentication secrets are created once in `data/security.json` and survive restarts. Keep that file with the persistent data volume. `JWT_SECRET` and `SESSION_SECRET` can override the generated secrets for managed deployments. + +For reverse proxies, set `APP_ORIGIN` to the external origin (for example `https://tv.example.com`) and `TRUST_PROXY` to the proxy's IP address or CIDR when it is outside the default loopback/private networks. HTTPS requests receive Secure cookies; `AUTH_COOKIE_SECURE=true` enforces this when TLS is terminated upstream. + +HTTP(S) media inputs reject control characters, local-file protocols, loopback and link-local/metadata addresses. Local IPTV services on ordinary LAN addresses remain supported. Set `ALLOW_LOCAL_MEDIA_URLS=true` when a provider deliberately runs on localhost; metadata addresses remain blocked. This URL validation complements deployment network restrictions; it does not resolve or pin every destination through nested FFmpeg requests. + ## License This project is licensed under the **GNU General Public License v3.0 (GPL-3.0)**. diff --git a/public/index.html b/public/index.html index ecd0658..6a4ef65 100644 --- a/public/index.html +++ b/public/index.html @@ -1096,7 +1096,7 @@ - + @@ -1109,21 +1109,8 @@ - - + + \ No newline at end of file diff --git a/public/js/api.js b/public/js/api.js index 77b1306..9250428 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -9,17 +9,12 @@ const API = { async request(method, endpoint, data = null) { const options = { method, + credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }; - // Add authentication token if available - const token = localStorage.getItem('authToken'); - if (token) { - options.headers['Authorization'] = `Bearer ${token}`; - } - if (data) { options.body = JSON.stringify(data); } @@ -40,7 +35,7 @@ const API = { if (response.status === 401) { localStorage.removeItem('authToken'); window.location.href = '/login.html'; - return; + throw new Error('Authentication required'); } throw new Error(result.error || `Server responded with ${response.status}`); } diff --git a/public/js/app.js b/public/js/app.js index c4d2f8c..5e8b4bd 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -28,7 +28,7 @@ class App { async init() { // Check authentication first - await this.checkAuth(); + if (!await this.checkAuth()) return; // Mobile menu toggle const mobileMenuToggle = document.getElementById('mobile-menu-toggle'); @@ -167,19 +167,14 @@ class App { async checkAuth() { const token = localStorage.getItem('authToken'); - if (!token) { - // No token, redirect to login (replace to avoid back button issues) - window.location.replace('/login.html'); - return; - } - try { - // Verify token with server - const response = await fetch('/api/auth/me', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); + if (token) { + const migrated = await fetch('/api/auth/session', { method: 'POST', + headers: { Authorization: `Bearer ${token}` } }); + localStorage.removeItem('authToken'); + if (!migrated.ok) throw new Error('Please sign in again'); + } + const response = await fetch('/api/auth/me', { credentials: 'same-origin' }); if (!response.ok) { throw new Error('Invalid token'); @@ -197,11 +192,13 @@ class App { // Add logout button to navbar this.addLogoutButton(); + return true; } catch (err) { console.error('Authentication error:', err); localStorage.removeItem('authToken'); window.location.replace('/login.html'); + return false; } } @@ -223,15 +220,7 @@ class App { logoutLink.addEventListener('click', async (e) => { e.preventDefault(); - const token = localStorage.getItem('authToken'); - if (token) { - await fetch('/api/auth/logout', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - } + await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); localStorage.removeItem('authToken'); window.location.replace('/login.html'); diff --git a/public/login.html b/public/login.html index 454acfe..644f7e9 100644 --- a/public/login.html +++ b/public/login.html @@ -299,7 +299,7 @@ } // Store token - localStorage.setItem('authToken', data.token); + localStorage.removeItem('authToken'); // Redirect to main app (replace history to prevent back button issues) window.location.replace('/'); diff --git a/server/auth.js b/server/auth.js index ac76206..5a372ae 100644 --- a/server/auth.js +++ b/server/auth.js @@ -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 }; diff --git a/server/config/security.js b/server/config/security.js new file mode 100644 index 0000000..425ff5f --- /dev/null +++ b/server/config/security.js @@ -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}` }; diff --git a/server/db.js b/server/db.js index b5a0512..3c1a054 100644 --- a/server/db.js +++ b/server/db.js @@ -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() }; diff --git a/server/index.js b/server/index.js index 0ff9807..f40049e 100644 --- a/server/index.js +++ b/server/index.js @@ -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 () => { diff --git a/server/routes/auth.js b/server/routes/auth.js index 73ce607..87cc571 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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 diff --git a/server/routes/probe.js b/server/routes/probe.js index 89f501e..f4a2e33 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -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); diff --git a/server/routes/proxy.js b/server/routes/proxy.js index b5d0b0d..a8ad4eb 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -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'); diff --git a/server/routes/remux.js b/server/routes/remux.js index 46fa78a..6d7031a 100644 --- a/server/routes/remux.js +++ b/server/routes/remux.js @@ -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' }); } diff --git a/server/routes/transcode.js b/server/routes/transcode.js index c5ae8e4..6fede23 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -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' }); } diff --git a/server/services/authTokens.js b/server/services/authTokens.js new file mode 100644 index 0000000..f3847e4 --- /dev/null +++ b/server/services/authTokens.js @@ -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 }; diff --git a/server/services/proxyStream.js b/server/services/proxyStream.js index cc92f57..05699df 100644 --- a/server/services/proxyStream.js +++ b/server/services/proxyStream.js @@ -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; diff --git a/server/services/requestLimits.js b/server/services/requestLimits.js new file mode 100644 index 0000000..ae96db4 --- /dev/null +++ b/server/services/requestLimits.js @@ -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 }; diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index 0b150fd..214a56d 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -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, diff --git a/server/services/urlSecurity.js b/server/services/urlSecurity.js new file mode 100644 index 0000000..61afd2a --- /dev/null +++ b/server/services/urlSecurity.js @@ -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 +}; diff --git a/test/authentication.test.js b/test/authentication.test.js new file mode 100644 index 0000000..c8dd583 --- /dev/null +++ b/test/authentication.test.js @@ -0,0 +1,128 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawn } = require('node:child_process'); +const jwt = require('jsonwebtoken'); + +async function serverFixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-auth-test-')); + const root = path.join(__dirname, '..'); + let child, base; + const stop = async () => { + if (!child || child.exitCode !== null) return; + const closed = new Promise(resolve => child.once('close', resolve)); + child.kill(); + await closed; + }; + const start = async () => { + child = spawn(process.execPath, ['server/index.js'], { cwd: root, + env: { ...process.env, PORT: '0', NODECAST_DATA_DIR: dir, + NODECAST_DISABLE_BACKGROUND_JOBS: 'true', JWT_SECRET: '', SESSION_SECRET: '', + OIDC_ISSUER_URL: '', ALLOW_LOCAL_MEDIA_URLS: 'true', AUTH_COOKIE_SECURE: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + base = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${output}`)), 15000); + child.stdout.on('data', chunk => { + output += chunk; + const match = output.match(/server running on http:\/\/localhost:(\d+)/); + if (match) { clearTimeout(timer); resolve(`http://127.0.0.1:${match[1]}`); } + }); + child.stderr.on('data', chunk => { output += chunk; }); + child.once('exit', code => { clearTimeout(timer); reject(new Error(`Server exited ${code}: ${output}`)); }); + child.once('error', reject); + }); + return base; + }; + t.after(async () => { + await stop(); + assert.equal(path.dirname(path.resolve(dir)), path.resolve(os.tmpdir())); + fs.rmSync(dir, { recursive: true, force: true }); + }); + await start(); + const request = async (endpoint, options = {}) => { + const response = await fetch(`${base}${endpoint}`, { ...options, + headers: { 'Content-Type': 'application/json', ...options.headers } }); + const body = await response.text(); + return { status: response.status, body: body ? JSON.parse(body) : null, + cookie: response.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_')), + headers: response.headers }; + }; + return { dir, request, start, stop, get base() { return base; } }; +} +const post = body => ({ method: 'POST', body: JSON.stringify(body) }); + +test('real application authenticates cookies and bearer clients, isolates users, and persists revocation', async t => { + const fixture = await serverFixture(t); + const { request } = fixture; + for (const route of ['/api/sources', '/api/proxy/stream?url=http://provider.test/a', '/api/probe', + '/api/remux', '/api/subtitle', '/api/transcode/sessions', '/api/settings', '/api/history']) { + assert.equal((await request(route)).status, 401, route); + } + const attempts = await Promise.all(['Admin', 'Other'].map(username => request('/api/auth/setup', post({ username, password: 'password123' })))); + assert.equal(attempts.filter(result => result.status === 201).length, 1); + const admin = attempts.find(result => result.status === 201); + const cookie = admin.cookie.split(';')[0]; + assert.match(admin.cookie, /HttpOnly/); + assert.match(admin.cookie, /SameSite=Lax/); + assert.equal(admin.body.user.passwordHash, undefined); + const adminHeaders = { Cookie: cookie }; + assert.equal((await request('/api/sources', { headers: adminHeaders })).status, 200); + assert.equal((await request('/api/auth/me', { headers: { Authorization: `Bearer ${admin.body.token}` } })).status, 200); + const viewer = await request('/api/auth/users', { ...post({ username: 'Viewer', password: 'password123', role: 'viewer' }), headers: adminHeaders }); + assert.equal(viewer.status, 201); + const login = await request('/api/auth/login', post({ username: 'Viewer', password: 'password123' })); + const viewerCookie = login.cookie.split(';')[0]; + const viewerHeaders = { Cookie: viewerCookie }; + assert.equal((await request('/api/auth/me', { headers: viewerHeaders })).body.role, 'viewer'); + for (const [route, method] of [['/api/settings', 'PUT'], ['/api/sources', 'POST'], ['/api/channels/hide', 'POST'], ['/api/auth/users', 'GET']]) { + assert.equal((await request(route, { method, headers: viewerHeaders, body: method === 'GET' ? undefined : '{}' })).status, 403, route); + } + assert.equal((await request('/api/settings', { headers: viewerHeaders })).status, 200); + assert.equal((await request('/api/remux?url=file:///etc/passwd', { headers: viewerHeaders })).status, 400); + assert.equal((await request('/api/probe?url=file:///etc/passwd', { headers: viewerHeaders })).status, 400); + assert.equal((await request('/api/transcode', { headers: viewerHeaders })).status, 400); + for (const route of ['/api/transcode?url=file:///etc/passwd', '/api/proxy/image?url=http://169.254.169.254/a', + '/api/subtitle?url=file:///etc/passwd&index=0']) assert.equal((await request(route, { headers: viewerHeaders })).status, 400, route); + assert.equal((await request('/api/auth/session', { method: 'POST', headers: { Authorization: `Bearer ${login.body.token}` } })).status, 200); + assert.equal((await request('/api/auth/logout', { method: 'POST', headers: viewerHeaders })).status, 200); + assert.equal((await request('/api/auth/me', { headers: viewerHeaders })).status, 401); + const secrets = fs.readFileSync(path.join(fixture.dir, 'security.json'), 'utf8'); + await fixture.stop(); await fixture.start(); + assert.equal(fs.readFileSync(path.join(fixture.dir, 'security.json'), 'utf8'), secrets); + assert.equal((await request('/api/auth/me', { headers: viewerHeaders })).status, 401); + assert.equal((await request('/api/auth/me', { headers: adminHeaders })).status, 200); + const secret = JSON.parse(secrets).jwt; + const invalid = jwt.sign({ id: admin.body.user.id, role: 'admin', kind: 'mfa' }, secret, + { audience: 'nodecast-tv', expiresIn: '5m', jwtid: 'temporary' }); + assert.equal((await request('/api/auth/me', { headers: { Authorization: `Bearer ${invalid}` } })).status, 401); + const nextLogin = await request('/api/auth/login', post({ username: 'Viewer', password: 'password123' })); + assert.equal(nextLogin.status, 200); + assert.equal((await request(`/api/auth/users/${viewer.body.id}`, { method: 'PUT', body: JSON.stringify({ password: 'changed123' }), headers: adminHeaders })).status, 200); + assert.equal((await request('/api/auth/me', { headers: { Authorization: `Bearer ${nextLogin.body.token}` } })).status, 401); +}); + +test('cross-site requests and spoofed forwarded hosts are blocked, and login attempts are bounded', async t => { + const { request, base } = await serverFixture(t); + assert.equal((await request('/api/auth/setup', { ...post({ username: 'Admin', password: 'password123' }), + headers: { Origin: 'https://untrusted.test', 'X-Forwarded-Host': 'untrusted.test' } })).status, 403); + assert.equal((await request('/api/auth/setup', { ...post({ username: 'Admin', password: 'password123' }), headers: { Origin: base } })).status, 201); + assert.equal((await request('/api/sources', { headers: { 'Sec-Fetch-Site': 'cross-site' } })).status, 403); + assert.equal((await request('/api/auth/login', { ...post({ username: 'Admin', password: 'password123' }), headers: { Origin: 'null' } })).status, 403); + for (let i = 0; i < 19; i++) assert.equal((await request('/api/auth/login', post({ username: 'Nobody', password: 'wrong' }))).status, 401); + const limited = await request('/api/auth/login', post({ username: 'Nobody', password: 'wrong' })); + assert.equal(limited.status, 429); + assert.ok(Number(limited.headers.get('Retry-After')) > 0); +}); + +test('separate installations reject each other\'s cookies even when user IDs match', async t => { + const first = await serverFixture(t), second = await serverFixture(t); + const one = await first.request('/api/auth/setup', post({ username: 'Admin', password: 'password123' })); + const two = await second.request('/api/auth/setup', post({ username: 'Admin', password: 'password123' })); + assert.equal(one.body.user.id, two.body.user.id); + assert.notEqual(one.cookie.split('=')[0], two.cookie.split('=')[0]); + assert.equal((await second.request('/api/auth/me', { headers: { Cookie: one.cookie.split(';')[0] } })).status, 401); + assert.equal((await first.request('/api/auth/me', { headers: { Cookie: two.cookie.split(';')[0] } })).status, 401); +}); diff --git a/test/browser-auth.test.js b/test/browser-auth.test.js new file mode 100644 index 0000000..4625ffb --- /dev/null +++ b/test/browser-auth.test.js @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +test('browser startup uses its cookie without requiring a stored token and migrates bearer tokens once', async () => { + for (const legacy of [false, true]) { + const storage = new Map(legacy ? [['authToken', 'old-token']] : []); + const requests = [], redirects = []; + const context = vm.createContext({ console: { error() {} }, + window: { location: { replace: url => redirects.push(url) } }, + document: { addEventListener() {}, querySelector: () => null }, + localStorage: { getItem: key => storage.get(key), removeItem: key => storage.delete(key) }, + fetch: async (url, options) => { requests.push({ url, options }); return { ok: true, json: async () => ({ id: 1, role: 'viewer' }) }; } }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8'), context); + const app = vm.runInContext('Object.create(App.prototype)', context); + app.addLogoutButton = () => {}; + assert.equal(await app.checkAuth(), true); + assert.deepEqual(requests.map(r => r.url), legacy ? ['/api/auth/session', '/api/auth/me'] : ['/api/auth/me']); + assert.equal(requests.at(-1).options.credentials, 'same-origin'); + assert.equal(storage.has('authToken'), false); + assert.equal(redirects.length, 0); + } +}); + +test('HTTP media URLs reject protected destinations and redact provider credentials', () => { + const filename = path.join(__dirname, '../server/services/urlSecurity.js'); + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, URL, process: { env: {} } }); + const { validateHttpUrl, redactText, redactUrl } = module.exports; + for (const value of ['file:///etc/passwd', 'http://localhost/a', 'http://127.0.0.1/a', + 'http://[::1]/a', 'http://[::ffff:127.0.0.1]/a', 'http://169.254.169.254/a', + 'http://[::ffff:169.254.169.254]/a', 'http://metadata.google.internal/a', 'http://provider.test/a\n', ['http://provider.test/a']]) { + assert.throws(() => validateHttpUrl(value), undefined, String(value)); + } + const url = 'https://name:secret@provider.test/live/name/password/1.ts?token=private'; + assert.equal(validateHttpUrl(url), url); + assert.equal(validateHttpUrl('http://192.168.1.10/provider'), 'http://192.168.1.10/provider'); + assert.equal(redactUrl(url), 'https://provider.test/…'); + assert.equal(redactText(`Input ${url} failed`), 'Input [redacted URL] failed'); +}); diff --git a/test/proxy-stream.test.js b/test/proxy-stream.test.js index 9098d4c..728fa69 100644 --- a/test/proxy-stream.test.js +++ b/test/proxy-stream.test.js @@ -1,3 +1,4 @@ +process.env.ALLOW_LOCAL_MEDIA_URLS = 'true'; // Local HTTP origin fixtures only. const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); diff --git a/test/remux.test.js b/test/remux.test.js index 16300f8..f818112 100644 --- a/test/remux.test.js +++ b/test/remux.test.js @@ -1,3 +1,5 @@ +// Synthetic HTTP fixtures explicitly opt in to loopback media. +process.env.ALLOW_LOCAL_MEDIA_URLS = 'true'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); diff --git a/test/session-ownership.test.js b/test/session-ownership.test.js new file mode 100644 index 0000000..b9edf17 --- /dev/null +++ b/test/session-ownership.test.js @@ -0,0 +1,32 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); + +test('session lookup, removal and URL helper cannot reuse another account\'s session', async t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-session-test-')); + t.after(() => { + assert.equal(path.dirname(path.resolve(dir)), path.resolve(os.tmpdir())); + fs.rmSync(dir, { recursive: true, force: true }); + }); + const filename = path.join(__dirname, '../server/services/transcodeSession.js'); + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, require: createRequire(filename), + process: { cwd: () => dir, env: {} }, console: { log() {}, error() {} }, setTimeout, clearTimeout, + setInterval, clearInterval }, { filename }); + const service = module.exports; + const one = await service.createSession('http://provider.test/movie', { ownerId: 1, videoMode: 'copy' }); + one.status = 'running'; + assert.equal(service.getSession(one.id, 2), null); + assert.equal(await service.removeSession(one.id, 2), false); + assert.equal(service.getSession(one.id, 1), one); + const two = await service.getOrCreateSession('http://provider.test/movie', { ownerId: 2, videoMode: 'encode' }); + assert.notEqual(two.id, one.id); + const changed = await service.getOrCreateSession('http://provider.test/movie', { ownerId: 1, seekOffset: 100 }); + assert.notEqual(changed.id, one.id); + for (const session of [one, two, changed]) assert.equal(await service.removeSession(session.id, session.ownerId), true); + assert.equal(service.getAllSessions().length, 0); +});