test/dashboard-preauth-body.test.js
Rama master · réplica de lectura del repositorio original.
// S03/G05 regression: `/dashboard/api/*` used to read and JSON-parse the whole
// request body BEFORE handleDashboardApi ran the Host/mutation/lockout/auth
// gate. An unauthenticated caller could therefore force up to MAX_BODY_SIZE
// (10 MiB) of buffering + JSON.parse per request, unauthenticated.
//
// The proof is deliberately end-to-end and body-absence based: the client sends
// a complete set of headers declaring a 10 MiB body and then sends NO body
// bytes at all. A server that reads the body first can never answer (it is
// waiting for an 'end' that never comes), so a 401 arriving while the body is
// still unsent can only come from a gate that ran before readBody(). Removing
// the preflight call in src/server.js makes this test time out — the red-first
// design check recorded in the brief.
//
// Controls keep the other side of the contract honest:
// - an authenticated mutation still receives its parsed body (the prefs
// response reflects the JSON that was sent);
// - `/auth/login` (server.js, outside /dashboard/api/*) still reads and
// parses its body, so the unauthenticated auth flows were not collateral.
import { after, afterEach, before, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { config } from '../src/config.js';
import { getAccountCount, _resetLockoutForTests } from '../src/auth.js';
import { _resetRuntimeConfigForTests } from '../src/runtime-config.js';
import { MAX_BODY_SIZE, startServer } from '../src/server.js';
const DASHBOARD_PASSWORD = 'preauth-body-test-password';
const saved = {
host: config.host,
port: config.port,
apiKey: config.apiKey,
dashboardPassword: config.dashboardPassword,
};
const savedEnv = {};
for (const key of ['WINDSURFAPI_ALLOW_UNAUTHENTICATED', 'DASHBOARD_ALLOW_NO_AUTH']) {
savedEnv[key] = process.env[key];
}
function restoreEnv(key) {
const value = savedEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
function resetAuthState() {
_resetRuntimeConfigForTests();
_resetLockoutForTests();
delete process.env.DASHBOARD_ALLOW_NO_AUTH;
config.apiKey = '';
config.dashboardPassword = '';
}
let server = null;
let port = 0;
before(async () => {
resetAuthState();
config.host = '127.0.0.1';
config.port = 0;
server = startServer();
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
port = server.address().port;
});
after(async () => {
if (server) {
server.closeAllConnections?.();
await new Promise(resolve => server.close(resolve));
}
config.host = saved.host;
config.port = saved.port;
config.apiKey = saved.apiKey;
config.dashboardPassword = saved.dashboardPassword;
for (const key of Object.keys(savedEnv)) restoreEnv(key);
});
beforeEach(resetAuthState);
afterEach(() => {
for (const key of Object.keys(savedEnv)) restoreEnv(key);
});
function parseJson(raw) {
try { return raw ? JSON.parse(raw) : null; } catch { return raw; }
}
// Ordinary request helper for the two positive controls.
function request(method, path, { headers = {}, body = null } = {}) {
return new Promise((resolve, reject) => {
const payload = body == null ? null : JSON.stringify(body);
const req = http.request({
host: '127.0.0.1',
port,
path,
method,
agent: false,
headers: {
...(payload == null ? {} : { 'Content-Length': Buffer.byteLength(payload) }),
...headers,
},
}, (res) => {
let raw = '';
res.setEncoding('utf8');
res.on('data', chunk => { raw += chunk; });
res.on('end', () => {
req.destroy();
resolve({ status: res.statusCode, body: parseJson(raw), raw });
});
});
req.on('error', reject);
if (payload != null) req.write(payload);
req.end();
});
}
// Sends the request line + headers (declaring a full MAX_BODY_SIZE body) and
// then never writes a body byte. Resolves with the response the server sent
// while the request was still incomplete.
function requestWithoutBody(path, { headers = {}, timeoutMs = 5000 } = {}) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
req.destroy();
fn(value);
};
const timer = setTimeout(() => {
finish(reject, new Error(
'no response before the request body was sent — the server read the body first',
));
}, timeoutMs);
const req = http.request({
host: '127.0.0.1',
port,
path,
method: 'POST',
agent: false,
headers: {
'Content-Length': String(MAX_BODY_SIZE),
...headers,
},
}, (res) => {
let raw = '';
res.setEncoding('utf8');
res.on('data', chunk => { raw += chunk; });
res.on('end', () => finish(resolve, { status: res.statusCode, body: parseJson(raw), raw }));
res.on('error', err => finish(reject, err));
});
// The server rejects before the body arrives and then closes the socket;
// an error AFTER the response was received is the expected teardown.
req.on('error', err => finish(reject, err));
req.flushHeaders();
});
}
describe('S03/G05: /dashboard/api/* body is not read before the auth gate', () => {
it('rejects an unauthenticated mutation while its 10 MiB body is still unsent', async () => {
const before = getAccountCount().total;
const r = await requestWithoutBody('/dashboard/api/self-update', {
headers: {
Host: `127.0.0.1:${port}`,
'Content-Type': 'application/json',
},
});
assert.equal(r.status, 401, r.raw);
assert.equal(r.body?.error, 'Unauthorized. Set X-Dashboard-Password header.');
// Same #257 error envelope handleDashboardApi produced before the move.
assert.equal(r.body?.success, false);
assert.equal(getAccountCount().total, before, 'a rejected mutation must have no side effect');
});
it('still hands an authenticated mutation its parsed body', async () => {
config.dashboardPassword = DASHBOARD_PASSWORD;
const r = await request('PUT', '/dashboard/api/settings/prefs', {
headers: {
Host: `127.0.0.1:${port}`,
'Content-Type': 'application/json',
'x-dashboard-password': DASHBOARD_PASSWORD,
},
body: { oauthSkipChooser: true },
});
assert.equal(r.status, 200, r.raw);
assert.equal(r.body?.success, true);
assert.equal(r.body?.prefs?.oauthSkipChooser, true, 'the handler must see the JSON body');
});
it('still answers the dashboard /auth credential probe', async () => {
// Regression for the extraction: the probe's success/failure counters use
// the resolved client IP, which the extracted gate no longer exposes to
// handleDashboardApi. A missing binding throws a ReferenceError here.
config.dashboardPassword = DASHBOARD_PASSWORD;
const headers = { Host: `127.0.0.1:${port}`, 'Content-Type': 'application/json' };
const wrong = await request('POST', '/dashboard/api/auth', {
headers: { ...headers, 'x-dashboard-password': 'wrong-password' },
body: {},
});
assert.equal(wrong.status, 200, wrong.raw);
assert.deepEqual(wrong.body, { required: true, valid: false });
const right = await request('POST', '/dashboard/api/auth', {
headers: { ...headers, 'x-dashboard-password': DASHBOARD_PASSWORD },
body: {},
});
assert.equal(right.status, 200, right.raw);
assert.deepEqual(right.body, { required: true, valid: true });
});
it('still reads the body of the unauthenticated /auth/login flow', async () => {
process.env.WINDSURFAPI_ALLOW_UNAUTHENTICATED = '1';
process.env.DASHBOARD_ALLOW_NO_AUTH = '1';
const before = getAccountCount().total;
// An empty `accounts` array only produces {results: []} if the body was
// parsed; an unread body would fall into the 400 "Provide api_key, token,
// or email+password" branch.
const r = await request('POST', '/auth/login', {
headers: {
Host: `127.0.0.1:${port}`,
'Content-Type': 'application/json',
},
body: { accounts: [] },
});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body?.results, []);
assert.equal(getAccountCount().total, before);
});
});