import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { once } from 'node:events'; import { WebSocket, WebSocketServer } from 'ws'; import { createGateway } from '../src/server.js'; import { fixture, grant } from './helpers.js'; async function setup(t, options) { const f = fixture(options); const received = []; const upstream = createServer(async (req, res) => { let body = ''; for await (const chunk of req) body += chunk; received.push({ url: req.url, headers: req.headers, method: req.method, body }); if (req.url === '/redirect') { res.writeHead(302, { location: `${f.config.upstreamUrl.origin}/dashboard?tab=party` }); return res.end(); } res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': ['app=abc; Domain=127.0.0.1; Path=/; HttpOnly', 'dng_access=forged; Path=/'], 'cache-control': 'public, max-age=3600' }); res.end(JSON.stringify(received.at(-1))); }); const wss = new WebSocketServer({ server: upstream }); wss.on('connection', (ws, req) => { received.push({ ws: true, headers: req.headers }); ws.on('message', data => ws.send(data)); }); upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); f.config.upstreamUrl = new URL(`http://127.0.0.1:${upstream.address().port}`); const gateway = createGateway(f.config, f.store, { log: { error() {} } }); gateway.server.listen(0, '127.0.0.1'); await once(gateway.server, 'listening'); f.config.publicUrl = new URL(`http://127.0.0.1:${gateway.server.address().port}`); const url = f.config.publicUrl.origin; t.after(async () => { await gateway.close(); for (const ws of wss.clients) ws.terminate(); wss.close(); upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)); }); return { ...f, url, received, upstream, gateway, login: (code, next = '/', headers = {}) => fetch(`${url}/_access/login`, { method: 'POST', headers: { origin: url, 'content-type': 'application/json', ...headers }, body: JSON.stringify({ code, next }) }), }; } async function deniedSocket(url, headers = {}) { return new Promise((resolve, reject) => { const ws = new WebSocket(url.replace('http:', 'ws:') + '/socket', { headers }); ws.on('unexpected-response', (_req, res) => { res.resume(); ws.terminate(); resolve(res.statusCode); }); ws.on('open', () => { ws.terminate(); reject(new Error('Unauthenticated websocket was accepted')); }); ws.on('error', () => {}); }); } test('unauthenticated pages, API, assets, HEAD, uploads and websocket upgrades never reach upstream', async t => { const f = await setup(t); const root = await fetch(f.url); assert.equal(root.status, 200); assert.match(await root.text(), /YOUR NEXT/); const page = await fetch(`${f.url}/campaigns/5?tab=round`, { headers: { accept: 'text/html' }, redirect: 'manual' }); assert.equal(page.status, 303); assert.equal(page.headers.get('location'), '/_access?next=%2Fcampaigns%2F5%3Ftab%3Dround'); for (const [method, path] of [['GET', '/api/v1/campaigns'], ['HEAD', '/_nuxt/file.js'], ['POST', '/upload'], ['GET', '/private?x=1']]) { assert.equal((await fetch(f.url + path, { method })).status, 401); } assert.equal((await fetch(`${f.url}/_access/unknown`)).status, 404); assert.equal(await deniedSocket(f.url), 401); assert.equal(f.received.length, 0); assert.equal((await fetch(`${f.url}/_access/assets/style.css`)).status, 200); }); test('a reusable code unlocks deep links and streams methods, bodies, application cookies and bearer auth', async t => { const f = await setup(t); const { code } = await grant(f); const result = await f.login(code, '/campaigns/123?tab=party'); assert.equal(result.status, 200); assert.deepEqual(await result.json(), { next: '/campaigns/123?tab=party' }); const setCookie = result.headers.get('set-cookie'); assert.match(setCookie, /HttpOnly; SameSite=Lax; Max-Age=1296000/); const cookie = setCookie.split(';')[0]; const response = await fetch(`${f.url}/api/v1/campaigns?x=1`, { method: 'PATCH', body: '{"name":"A new world"}', headers: { cookie: `${cookie}; app=existing`, authorization: 'Bearer upstream-token', 'content-type': 'application/json', 'x-forwarded-for': '192.0.2.1', 'x-forwarded-host': 'evil.example', 'x-forwarded-proto': 'ftp', 'x-dng-user': 'forged', } }); const body = await response.json(); assert.equal(body.url, '/api/v1/campaigns?x=1'); assert.equal(body.method, 'PATCH'); assert.equal(body.body, '{"name":"A new world"}'); assert.equal(body.headers.cookie, 'app=existing'); assert.equal(body.headers.authorization, 'Bearer upstream-token'); assert.equal(body.headers['x-forwarded-host'], f.config.publicUrl.host); assert.equal(body.headers['x-forwarded-proto'], 'http'); assert.equal(body.headers['x-forwarded-for'], '127.0.0.1'); assert.equal(body.headers['x-dng-user'], undefined); assert.equal(response.headers.get('cache-control'), 'private, no-store'); assert.match(response.headers.get('set-cookie'), /app=abc/); assert.doesNotMatch(response.headers.get('set-cookie'), /Domain|dng_access/); const redirect = await fetch(`${f.url}/redirect`, { headers: { cookie }, redirect: 'manual' }); assert.equal(redirect.headers.get('location'), `${f.url}/dashboard?tab=party`); assert.equal((await f.login(code)).status, 200); assert.equal(await deniedSocket(f.url), 401, 'proxy must not attach an unguarded listener after HTTP traffic'); assert.equal((await fetch(`${f.url}/_access/logout`, { method: 'POST', headers: { origin: f.url, cookie } })).status, 200); assert.equal((await fetch(`${f.url}/api`, { headers: { cookie } })).status, 401); }); test('WebSockets authenticate, preserve application cookies, reject cross-origin access, and close at expiry', async t => { const f = await setup(t); const { code } = await grant(f); const cookie = (await f.login(code)).headers.get('set-cookie').split(';')[0]; assert.equal(await deniedSocket(f.url, { cookie, origin: 'https://evil.example' }), 401); const ws = new WebSocket(f.url.replace('http:', 'ws:') + '/socket', { headers: { cookie: `${cookie}; app=ws`, origin: f.url } }); await once(ws, 'open'); const echo = once(ws, 'message'); ws.send('round:ready'); assert.equal((await echo)[0].toString(), 'round:ready'); assert.equal(f.received.find(r => r.ws).headers.cookie, 'app=ws'); const closed = once(ws, 'close'); f.advance(15 * 86400_000); await closed; assert.equal((await f.login(code)).status, 401); }); test('login rejects cross-origin posts, malformed bodies, forged cookies and rate-limit evasion', async t => { const f = await setup(t, { loginAttempts: 2 }); assert.equal((await f.login('0000000000', '/', { origin: 'https://evil.example' })).status, 403); assert.equal((await fetch(`${f.url}/_access/login`)).status, 405); assert.equal((await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'text/plain' }, body: '{}' })).status, 415); const malformed = await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'application/json' }, body: '{' }); assert.equal(malformed.status, 400); assert.equal((await f.login('0000000000')).status, 401); const limited = await f.login('0000000000', '/', { 'x-forwarded-for': '203.0.113.1' }); assert.equal(limited.status, 429); assert.equal(limited.headers.get('retry-after'), '900'); assert.equal((await fetch(`${f.url}/api`, { headers: { cookie: `dng_access=${'a'.repeat(43)}` } })).status, 401); }); test('oversized login requests are bounded and upstream outages return a usable 502', async t => { const f = await setup(t); const oversized = await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'application/json' }, body: JSON.stringify({ code: '1'.repeat(5000) }) }); assert.equal(oversized.status, 413); const { code } = await grant(f); const cookie = (await f.login(code, '//evil.example')).headers.get('set-cookie').split(';')[0]; f.upstream.closeAllConnections(); await new Promise(resolve => f.upstream.close(resolve)); const response = await fetch(`${f.url}/dashboard`, { headers: { cookie } }); assert.equal(response.status, 502); assert.match((await response.json()).error, /temporarily unreachable/); });