Authenticate browser media with durable HttpOnly login cookies

Adapted cookie, origin, URL validation and secret handling from MikaelKW/nodecast-tv-plus. Protect API roles, revoke logins across restart, preserve existing accounts, and isolate transcode sessions by account.

Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
Technomancer702
2026-10-12 16:53:15 -07:00
parent 2999d6542f
commit 1f665dcb11
24 changed files with 553 additions and 111 deletions

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