Files
Dungeons-Proxy/test/gateway.test.js

142 lines
8.2 KiB
JavaScript

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