DUNGEONS-30: add access gateway with Telegram and Discord approvals
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
141
test/gateway.test.js
Normal file
141
test/gateway.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user