Merge pull request #161 from technomancer702/codex/media-authentication

Authenticate media requests with durable HttpOnly cookies and account isolation
This commit is contained in:
Technomancer702
2026-10-12 16:56:35 -07:00
committed by GitHub
24 changed files with 553 additions and 111 deletions

View File

@ -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 <token>`. 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)**.

View File

@ -1096,7 +1096,7 @@
<!-- JavaScript -->
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script>
<script src="/js/api.js?v=auth-2"></script>
<script src="/js/components/VodDuration.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=2"></script>
<script src="/js/components/ChannelList.js?v=3"></script>
@ -1109,21 +1109,8 @@
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/pages/WatchPage.js?v=2"></script>
<script src="/js/app.js?v=4"></script>
<script>
// Check for SSO token in URL
(function () {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
console.log('SSO Login successful, saving token...');
localStorage.setItem('authToken', token);
// Clean URL without reloading
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
})();
</script>
<script src="/js/app.js?v=5"></script>
</body>
</html>

View File

@ -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}`);
}

View File

@ -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');

View File

@ -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('/');

View File

@ -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
View 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}` };

View File

@ -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()
};

View File

@ -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 () => {

View File

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

View File

@ -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);

View File

@ -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');

View File

@ -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' });
}

View File

@ -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' });
}

View 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 };

View File

@ -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;

View 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 };

View File

@ -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,

View 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
};

128
test/authentication.test.js Normal file
View File

@ -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);
});

42
test/browser-auth.test.js Normal file
View File

@ -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');
});

View File

@ -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');

View File

@ -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');

View File

@ -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);
});