GitNexus/docker-server.test.mjs
Shifra Williams ee0b216cfc
feat(serve): gate the /api/* proxy behind a Bearer token at the public edge (#8)
* feat(serve): gate the /api/* proxy behind a Bearer token at the public edge

The web service proxies /api/* straight through to a private API that has
no auth of its own, and deliberately strips Origin on the way — which also
strips the API's CSRF backstop. Every write route was therefore reachable
by anyone with the public URL: DELETE /api/repo, POST /api/analyze,
DELETE /api/analyze/:jobId, POST /api/mcp, plus unauthenticated reads of
every indexed repo's source.

Check GITNEXUS_SERVE_AUTH_TOKEN here, on the way in, before body buffering
and before the upstream socket opens — attaching a token on the way out
would move the exposure a hop rather than remove it. Constant-time compare
mirrors createAuthMiddleware in gitnexus/src/mcp/http-transport.ts,
equal-length dummy comparison included. Rejections answer a well-formed
401 with WWW-Authenticate: Bearer and a stable `code` for the UI to
dispatch on. Static assets stay ungated: the UI has to load to prompt for
the token. The token is compared against, never injected next to
backendUrl in __GITNEXUS_CONFIG__.

Refuse to boot when GITNEXUS_UPSTREAM_URL is set without a token, so the
misconfiguration fails closed rather than per request. This substitutes
for the promised http-transport.ts refusal: that file's non-loopback check
lives in a package this branch no longer touches, and a `serve` behind a
private service with no token is a legitimate configuration now that the
trust boundary is the proxy.

Add GITNEXUS_PROXY_TRUST_XFF. clientAddressFor took the rightmost
X-Forwarded-For entry unconditionally, which is correct only behind a load
balancer that appends the real peer; with nothing in front, the caller
writes the whole chain and pop() returns an address they chose —
reinstating the rate-limit bypass the function exists to prevent. Default
off (forward the socket peer), same boolean grammar as
GITNEXUS_TRUST_PROXY including `true` rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(serve): correct three claims the token-gate comments got wrong

No behavior change — docker-server.mjs is comment-only.

The GITNEXUS_PROXY_TRUST_XFF comment claimed the same boolean grammar as
GITNEXUS_TRUST_PROXY, but resolveTrustProxy rejects `yes`/`on` (it also
takes a hop count and a proxy list, so bare words are ambiguous there)
while this plain-boolean knob accepts them. State what is actually shared:
the vocabulary and the `true` refusal.

The Authorization-forwarding comment justified itself with "/api/mcp
expects a Bearer header of its own" — it does not: mcp-http.ts mounts
/api/mcp unguarded and `serve` reads no Authorization header anywhere. The
choice still stands, on the honest grounds that it is inert today and
keeps the hop transparent, with the log-exposure cost named.

Cite resolveAuthToken for the blank-is-absent token semantics, which here
also keeps a whitespace-only value from slipping past the boot refusal.

Tests: hoist the repeated Bearer literal, spell out the header in the
happy-path case instead of leaning on rawRequest's default, and make
runUntilExit reject with a named error when the server starts rather than
resolving a null exit code that reads as an unrelated assertion failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): close the four gaps the token gate left open

Strip Authorization before forwarding, rather than passing it through.
The edge token is spent at this hop: `serve` reads no Authorization header
anywhere (mcp-http.ts mounts /api/mcp unguarded), so forwarding bought
nothing and copied a live credential into another service's request logs.
When the private upstream grows auth of its own it will want its own
token, not the browser's.

Refuse to boot on a token under 32 characters. Rejected requests are
answered at the edge and never reach the upstream's rate limiter, so the
public URL accepts guesses as fast as it can serve them. An in-process
throttle is the wrong fix — evictable per-address state in a stateless
proxy, plus a lockout an attacker can aim at a real user. A length floor
is stateless and makes guessing hopeless instead; `generateValue: true`
clears it comfortably, so it only ever rejects a hand-picked token, which
is the case worth rejecting.

Write both refusals with fs.writeSync rather than console.error. Node's
process I/O contract makes stderr asynchronous when it is a pipe (macOS),
so console.error immediately followed by process.exit can drop the one
message that explains why the container won't start.

Send Connection: close on the 401. We answer before reading the body, so
Node would otherwise drain up to 64KB of an unauthenticated caller's
upload to keep the socket reusable — the same reason the stalled-body 400
already closes.

Tests: split rawRequest (never authenticates) from apiRequest (does), so
no test in a file about who gets let through passes because a helper
quietly authenticated for it. Add coverage for the length floor, for a
whitespace-only token landing on the missing-token refusal rather than the
length one, for Connection: close, and flip the Authorization test to
assert the strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(serve): trim the token-gate comments to the file's own density

The comments added with the edge token gate ran five to fourteen lines
where the surrounding file runs two to three, and repeated the same
argument across paragraphs. Same claims, stated once.

Also corrects a stale reference: the auth default lives in apiRequest,
not rawRequest, since the helpers were split.

Comment-only; 56/56 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(serve): fold the two unauthenticated-request cases into one

Both spawned a server to make the same credential-less GET; the second
was a strict superset of the first's setup. One test now asserts the
whole contract: 401, WWW-Authenticate, stable code, upstream untouched.

Also drops an assertion that 'x'.repeat(n).length === n, which tested
String.prototype rather than the code under test.

55/55 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(serve): tighten the token-gate comments again

Every comment this branch added to docker-server.mjs, cut to its claim:
the eight blocks go from 44 lines to 33 with no reason dropped.

Comment-only; 55/55 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:04:03 -07:00

1085 lines
40 KiB
JavaScript

import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import { connect } from 'node:net';
import http, { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { after, before, it } from 'node:test';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverScript = join(__dirname, 'docker-server.mjs');
function getFreePort() {
return new Promise((resolve) => {
const s = createServer();
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
}
function rawGet(port, path) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
});
req.on('error', reject);
req.end();
});
}
async function waitForServer(port, retries = 30) {
for (let i = 0; i < retries; i++) {
try {
await rawGet(port, '/');
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error('Server did not start in time');
}
let tmpDir, serverPort, child;
before(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-'));
const distDir = join(tmpDir, 'dist');
const assetsDir = join(distDir, 'assets');
await mkdir(assetsDir, { recursive: true });
await writeFile(join(distDir, 'index.html'), '<html><body>spa</body></html>');
await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")');
serverPort = await getFreePort();
child = spawn(process.execPath, [serverScript], {
cwd: tmpDir,
env: { ...process.env, PORT: String(serverPort) },
stdio: 'pipe',
});
child.on('error', (err) => {
throw err;
});
await waitForServer(serverPort);
});
function killAndWait(proc) {
return new Promise((resolve) => {
if (!proc || proc.exitCode !== null) {
resolve();
return;
}
proc.once('exit', resolve);
proc.kill();
if (proc.exitCode !== null) resolve();
});
}
after(async () => {
await killAndWait(child);
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('serves a valid asset with immutable cache header', async () => {
const res = await rawGet(serverPort, '/assets/app.abc123.js');
assert.equal(res.status, 200);
assert.match(res.headers['cache-control'], /immutable/);
assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin');
assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp');
});
it('serves SPA fallback for unknown routes', async () => {
const res = await rawGet(serverPort, '/some/unknown/route');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
assert.match(res.headers['cache-control'], /no-cache/);
});
it('rejects path traversal with 400', async () => {
const res = await rawGet(serverPort, '/../../../etc/passwd');
assert.equal(res.status, 400);
});
it('rejects percent-encoded null bytes with 400', async () => {
const res = await rawGet(serverPort, '/foo%00bar');
assert.equal(res.status, 400);
});
it('rejects percent-encoded path traversal with 400', async () => {
// %2e%2e%2f decodes to '../'. Without the path.relative inline barrier,
// a naive string check on the raw URL would let this through and only
// the lexical-decoded path.resolve would catch it. Confirm the barrier
// does its job after decodeURIComponent.
const res = await rawGet(serverPort, '/%2e%2e%2f%2e%2e%2fetc%2fpasswd');
assert.equal(res.status, 400);
});
it('rejects malformed percent-encoding with 400', async () => {
// %GG is not a valid percent-encoded sequence — decodeURIComponent throws.
// The handler's try/catch around decode must convert this to a 400 rather
// than an unhandled rejection.
const res = await rawGet(serverPort, '/foo%GGbar');
assert.equal(res.status, 400);
});
it('returns 404 when dist/index.html is missing', async () => {
await unlink(join(tmpDir, 'dist', 'index.html'));
const res = await rawGet(serverPort, '/nonexistent-page');
assert.equal(res.status, 404);
});
// -- Config injection: server-level integration tests ---
function spawnServerWithEnv(cwd, port, env) {
const proc = spawn(process.execPath, [serverScript], {
cwd,
env: { ...process.env, PORT: String(port), ...env },
stdio: 'pipe',
});
proc.on('error', (err) => {
throw err;
});
return proc;
}
async function withInjectionServer(envOverrides, fn) {
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-inject-'));
const distDir = join(dir, 'dist');
const assetsDir = join(distDir, 'assets');
await mkdir(assetsDir, { recursive: true });
await writeFile(
join(distDir, 'index.html'),
'<!doctype html><html><head><meta charset="utf-8"></head><body>app</body></html>',
);
await writeFile(join(assetsDir, 'style.abc.css'), 'body{}');
const port = await getFreePort();
const proc = spawnServerWithEnv(dir, port, envOverrides);
try {
await waitForServer(port);
await fn(port);
} finally {
await killAndWait(proc);
await rm(dir, { recursive: true, force: true });
}
}
it('injects __GITNEXUS_CONFIG__ into / when GITNEXUS_BACKEND_URL is valid', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
res.body.includes('window.__GITNEXUS_CONFIG__'),
'Expected __GITNEXUS_CONFIG__ in response body',
);
assert.ok(res.body.includes('http://10.0.0.1:4747'), 'Expected backend URL in response body');
});
});
it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
const res = await rawGet(port, '/some/deep/link');
assert.equal(res.status, 200);
assert.ok(
res.body.includes('window.__GITNEXUS_CONFIG__'),
'Expected __GITNEXUS_CONFIG__ in SPA fallback response',
);
assert.ok(
res.body.includes('http://10.0.0.1:4747'),
'Expected backend URL in SPA fallback response',
);
});
});
it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => {
await withInjectionServer({}, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
!res.body.includes('__GITNEXUS_CONFIG__'),
'Expected no __GITNEXUS_CONFIG__ when env var is unset',
);
});
});
it('does not inject when GITNEXUS_BACKEND_URL is invalid', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'not-a-url' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
!res.body.includes('__GITNEXUS_CONFIG__'),
'Expected no __GITNEXUS_CONFIG__ for invalid URL',
);
});
});
it('does not inject when GITNEXUS_BACKEND_URL uses a non-http protocol', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'ftp://somehost:21' }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(
!res.body.includes('__GITNEXUS_CONFIG__'),
'Expected no __GITNEXUS_CONFIG__ for non-http protocol',
);
});
});
it('escapes </script> in GITNEXUS_BACKEND_URL to prevent XSS', async () => {
const xssUrl = 'http://example.com/?x=</script><script>alert(1)</script>';
await withInjectionServer({ GITNEXUS_BACKEND_URL: xssUrl }, async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
const scriptMatches = res.body.match(/<script>/gi) || [];
assert.equal(
scriptMatches.length,
1,
`Expected exactly 1 <script> tag but found ${scriptMatches.length}: XSS breakout detected`,
);
assert.ok(
!res.body.includes('</script><script>'),
'</script> must not appear unescaped -- would allow script breakout',
);
assert.ok(res.body.includes('\\u003c'), 'Angle brackets must be escaped as \\u003c');
});
});
it('does not inject config into static assets', async () => {
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
const res = await rawGet(port, '/assets/style.abc.css');
assert.equal(res.status, 200);
assert.ok(
!res.body.includes('__GITNEXUS_CONFIG__'),
'Static assets must not contain injected config',
);
assert.equal(res.body, 'body{}');
});
});
// -- API reverse proxy (GITNEXUS_UPSTREAM_URL) -----------------------------
// Every proxy fixture below runs the server with this token: the proxy refuses
// to start without one, and refuses one under 32 characters.
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`;
// rawRequest never sends credentials; apiRequest does. In a file whose subject
// is who gets let through, no test should pass because a helper quietly
// authenticated for it.
function rawRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
// Send an explicit Content-Length like a browser fetch() does — the proxy
// only buffers (and so only retries) bodies of known length.
const outHeaders = { ...headers };
if (
body !== undefined &&
!Object.keys(outHeaders).some((h) => h.toLowerCase() === 'content-length')
) {
outHeaders['content-length'] = String(Buffer.byteLength(body));
}
return new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port, path, method, headers: outHeaders },
(res) => {
let respBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
respBody += chunk;
});
res.on('end', () =>
resolve({ status: res.statusCode, headers: res.headers, body: respBody }),
);
},
);
req.on('error', reject);
if (body !== undefined) req.write(body);
req.end();
});
}
// An authenticated /api/* call. An explicit `authorization` header wins, so the
// auth tests can send a wrong one.
function apiRequest(port, path, { headers = {}, ...rest } = {}) {
const hasAuth = Object.keys(headers).some((h) => h.toLowerCase() === 'authorization');
return rawRequest(port, path, {
...rest,
headers: hasAuth ? headers : { ...headers, authorization: TEST_BEARER },
});
}
const respondOk = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end('{"ok":true}');
};
// Every proxy test needs the same four parts: a dist/ to serve, a fake upstream,
// a docker-server pointed at it, and teardown that leaks neither a process nor a
// temp dir. They differ only in how the upstream misbehaves.
//
// upstream request handler, replaceable mid-test via `ctx.handler`;
// null points the proxy at a port nothing ever listens on
// listenAfterMs bind the upstream this late, so the first attempt(s) hit
// ECONNREFUSED (a single-instance restart window)
// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's
// `fromService: { property: hostport }` yields it
// env extra environment for docker-server.mjs
//
// `ctx` collects what the upstream saw (calls, last request, last body) plus the
// proxy's stderr, so assertions read off one object.
async function withProxy(
{ upstream = respondOk, listenAfterMs = 0, schemeless = false, env = {} } = {},
fn,
) {
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-proxy-'));
await mkdir(join(dir, 'dist'), { recursive: true });
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
const ctx = { calls: 0, received: null, body: null, stderr: '', handler: upstream };
// Read the forwarded request to completion before handing it to the handler,
// so no test has to repeat that plumbing to assert on headers or body.
const server = upstream
? createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
ctx.calls += 1;
ctx.body = body;
ctx.received = { method: req.method, url: req.url, headers: req.headers, body };
ctx.handler(req, res);
});
})
: null;
// A late (or never) bind needs its port reserved up front; otherwise let the
// OS assign one at listen time.
const upstreamPort =
server && listenAfterMs === 0
? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port)))
: await getFreePort();
const bindTimer =
server && listenAfterMs > 0
? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs)
: null;
const port = await getFreePort();
const target = `127.0.0.1:${upstreamPort}`;
const proc = spawnServerWithEnv(dir, port, {
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
...env,
});
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => {
ctx.stderr += chunk;
});
try {
await waitForServer(port);
await fn(port, ctx);
} finally {
if (bindTimer) clearTimeout(bindTimer);
await killAndWait(proc);
if (server?.listening) {
server.closeAllConnections?.();
await new Promise((r) => server.close(r));
}
await rm(dir, { recursive: true, force: true });
}
}
it('proxies /api/* requests to the upstream server', async () => {
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/info?x=1');
assert.equal(res.status, 200);
assert.match(res.body, /"ok":true/);
assert.equal(ctx.received.url, '/api/info?x=1', 'path + query forwarded verbatim');
});
});
it('forwards the request method and body to the upstream', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"q":"hello"}',
});
assert.equal(ctx.received.method, 'POST');
assert.equal(ctx.received.body, '{"q":"hello"}');
});
});
it('strips the browser Origin and Referer before forwarding to the API', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: { origin: 'https://gitnexus-web.onrender.com', referer: 'https://x/y' },
});
assert.equal(
ctx.received.headers.origin,
undefined,
'Origin must be stripped so the API treats it as a trusted server-to-server call',
);
assert.equal(ctx.received.headers.referer, undefined, 'Referer must be stripped');
});
});
it('strips hop-by-hop headers before forwarding to the API', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: {
'keep-alive': 'timeout=5',
upgrade: 'h2c',
'proxy-authorization': 'Basic abc',
te: 'trailers',
},
});
assert.equal(ctx.received.headers['keep-alive'], undefined);
assert.equal(ctx.received.headers.upgrade, undefined);
assert.equal(ctx.received.headers['proxy-authorization'], undefined);
assert.equal(ctx.received.headers.te, undefined);
});
});
it('strips request headers that Connection names as single-hop', async () => {
await withProxy({}, async (port, ctx) => {
// RFC 7230 §6.1 lets Connection name hop-by-hop headers beyond the
// well-known eight, and those must not be forwarded either. Against a fixed
// list alone, x-custom-hop reaches the upstream.
await apiRequest(port, '/api/info', {
headers: { connection: 'x-custom-hop', 'x-custom-hop': 'private' },
});
assert.equal(ctx.received.headers['x-custom-hop'], undefined);
// Connection itself is always re-derived by Node for the upstream hop, so
// assert the client's value didn't survive rather than that it's absent.
assert.notEqual(ctx.received.headers.connection, 'x-custom-hop');
});
});
it('collapses a spoofed X-Forwarded-For chain to the load balancer entry when XFF is trusted', async () => {
const env = { GITNEXUS_PROXY_TRUST_XFF: '1' };
await withProxy({ env }, async (port, ctx) => {
// With a load balancer in front, only the last entry is the LB's; the rest
// is client-supplied and would otherwise let a caller fake req.ip and evade
// the API's rate limits.
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
});
assert.equal(ctx.received.headers['x-forwarded-for'], '203.0.113.9');
});
});
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is unset', async () => {
await withProxy({}, async (port, ctx) => {
// With nothing in front of the proxy, the whole chain is the caller's to
// write, so popping it would forward an address they chose.
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
});
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
});
});
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is off', async () => {
const env = { GITNEXUS_PROXY_TRUST_XFF: 'off' };
await withProxy({ env }, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '203.0.113.9' },
});
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
});
});
it('warns and falls back to ignoring XFF when GITNEXUS_PROXY_TRUST_XFF is "true"', async () => {
// Rejected for the same reason resolveTrustProxy rejects it server-side: it
// reads as "trust everything", the configuration this knob exists to make
// deliberate.
const env = { GITNEXUS_PROXY_TRUST_XFF: 'true' };
await withProxy({ env }, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '203.0.113.9' },
});
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
assert.match(
ctx.stderr,
/GITNEXUS_PROXY_TRUST_XFF "true" is not a recognized boolean/,
'an unrecognized value must warn rather than fail silently',
);
});
});
it('forwards the socket peer, not the rotating header, on every authenticated request', async () => {
// A caller rotating X-Forwarded-For per request earns a fresh limiter key
// upstream unless this proxy overwrites it. Hitting the API server directly
// would test its own trust-proxy handling instead of this hop.
await withProxy({}, async (port, ctx) => {
const forwarded = [];
for (const spoofed of ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4']) {
await apiRequest(port, '/api/query', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': spoofed },
body: '{"q":"hi"}',
});
forwarded.push(ctx.received.headers['x-forwarded-for']);
}
assert.equal(ctx.calls, 4);
for (const address of forwarded) {
assert.match(
address,
/127\.0\.0\.1$/,
'every request must key off the socket peer, not the value the client rotated',
);
}
});
});
it('sets X-Forwarded-For from the socket peer when the client sends none', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/info');
assert.match(
ctx.received.headers['x-forwarded-for'],
/127\.0\.0\.1$/,
'the API must always see a proxy-derived client address',
);
});
});
it('strips hop-by-hop headers from the upstream response', async () => {
const upstream = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain', Trailer: 'X-Late' });
res.end('ok');
};
await withProxy({ upstream }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(res.headers.trailer, undefined, 'Trailer describes the upstream hop only');
assert.equal(res.body, 'ok');
});
});
it('strips response headers that Connection names as single-hop', async () => {
const upstream = (_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
Connection: 'x-upstream-hop',
'x-upstream-hop': 'internal',
});
res.end('ok');
};
await withProxy({ upstream }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(res.headers['x-upstream-hop'], undefined, 'named on the upstream hop only');
});
});
it('does NOT proxy non-/api routes (still serves the SPA)', async () => {
await withProxy({}, async (port, ctx) => {
const res = await rawRequest(port, '/some/app/route');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
assert.equal(ctx.calls, 0, 'non-/api requests must not reach the upstream');
});
});
it('streams a chunked upstream response through to the client', async () => {
const upstream = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write('data: one\n\n');
setTimeout(() => {
res.write('data: two\n\n');
res.end();
}, 20);
};
await withProxy({ upstream }, async (port) => {
const res = await apiRequest(port, '/api/stream');
assert.equal(res.status, 200);
assert.equal(res.headers['content-type'], 'text/event-stream');
assert.match(res.body, /data: one/);
assert.match(res.body, /data: two/);
});
});
it('accepts a scheme-less host:port upstream (Render fromService hostport)', async () => {
await withProxy({ schemeless: true }, async (port, ctx) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(ctx.received.url, '/api/info', 'scheme-less upstream should still be proxied');
});
});
it('serves RENDER_EXTERNAL_URL as the backend origin when GITNEXUS_BACKEND_URL is unset', async () => {
await withInjectionServer(
{ RENDER_EXTERNAL_URL: 'https://gitnexus-web.onrender.com' },
async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
assert.ok(res.body.includes('window.__GITNEXUS_CONFIG__'));
assert.ok(res.body.includes('https://gitnexus-web.onrender.com'));
},
);
});
it('returns 504 when the upstream does not respond within the timeout', async () => {
// Upstream accepts the connection but never responds — an idle hang.
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '300' };
await withProxy({ upstream: () => {}, env }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 504);
});
});
it('returns 502 when the upstream is unreachable', async () => {
// Retry disabled so this fails fast (the unreachable-upstream contract).
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '1' };
await withProxy({ upstream: null, env }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 502);
});
});
// -- Connection-retry across an upstream restart window ---------------------
//
// `listenAfterMs: 400` binds the upstream late, so the first attempt hits
// ECONNREFUSED and must be retried — a single-instance restart. The default 3
// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind.
it('retries a connection-refused POST and succeeds once the upstream is up', async () => {
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
const res = await apiRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"repo":"x"}',
});
assert.equal(res.status, 200, 'first attempt should ride out the restart gap');
assert.match(res.body, /"ok":true/);
assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)');
assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact');
});
});
it('retries a bodyless DELETE, which frames no body to replay', async () => {
// Retry eligibility follows RFC 7230 §3.3.3 framing. A DELETE with neither
// Content-Length nor Transfer-Encoding has nothing to buffer, so it replays
// safely even though it isn't a GET.
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
const res = await apiRequest(port, '/api/repo', { method: 'DELETE' });
assert.equal(res.status, 200, 'a bodyless DELETE must ride out the restart gap');
assert.equal(ctx.calls, 1);
});
});
it('falls back to the default retry budget when the knob is out of range', async () => {
// A negative attempt count is a typo. Obeying it would turn every restart
// window into a 502, silently.
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '-1' };
await withProxy({ listenAfterMs: 400, env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(ctx.calls, 1);
});
});
it('warns and keeps the default when a timeout knob is negative', async () => {
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '-1' };
await withProxy({ upstream: null, env }, async (_port, ctx) => {
// Every consumer reads <= 0 as "disabled", so an unvalidated -1 removes the
// idle timeout and lets a proxied request hang forever.
assert.match(
ctx.stderr,
/GITNEXUS_PROXY_TIMEOUT_MS "-1" is below the minimum 0 -- using 120000/,
);
});
});
it('does NOT retry after the client aborts during the backoff window', async () => {
// The client aborts (~100ms) while a retry is pending, before the upstream
// binds (~400ms). The backoff guard must cancel it — otherwise the retry
// lands after the bind and runs a job nobody is waiting on.
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
await new Promise((resolve) => {
const req = http.request({
host: '127.0.0.1',
port,
path: '/api/analyze',
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': '12',
authorization: TEST_BEARER,
},
});
req.on('error', () => {}); // aborting surfaces a local socket error; ignore
req.write('{"repo":"x"}');
req.end();
// Abort after the first attempt has failed-and-scheduled (ECONNREFUSED is
// near-instant) but well before the upstream binds at ~400ms.
setTimeout(() => {
req.destroy();
resolve();
}, 100);
});
// Wait past the upstream bind + full retry budget (~750ms) so a leaked retry
// would already have landed.
await new Promise((r) => setTimeout(r, 900));
assert.equal(ctx.calls, 0, 'aborted request must not be retried against the upstream');
});
});
it('returns 502 after exhausting the retry budget when the upstream stays down', async () => {
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
await withProxy({ upstream: null, env }, async (port) => {
const res = await apiRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"repo":"x"}',
});
assert.equal(res.status, 502, 'genuinely-down upstream still returns 502 after the budget');
});
});
it('does NOT retry a POST that connects then resets before responding', async () => {
// The upstream accepts the connection, reads the whole request, then dies
// before sending any response byte — an instance that received the job and
// crashed/restarted mid-flight. Because the reset arrives AFTER connecting and
// POST is non-idempotent, replaying could run the job twice, so the proxy must
// NOT retry: the upstream sees exactly one call and the browser gets 502.
const upstream = (_req, res) => res.socket.destroy();
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
await withProxy({ upstream, env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"repo":"x"}',
});
assert.equal(res.status, 502, 'post-connection reset on a POST fails fast, no retry');
// Give any (erroneous) retry a chance to fire before asserting.
await new Promise((r) => setTimeout(r, 300));
assert.equal(
ctx.calls,
1,
'non-idempotent POST must not be replayed after the upstream got it',
);
});
});
it('does NOT retry after the upstream starts streaming, then drops mid-body', async () => {
// Send headers + a partial body, then abruptly destroy the socket.
const upstream = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write('{"partial":');
setTimeout(() => res.socket.destroy(), 20);
};
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
await withProxy({ upstream, env }, async (port, ctx) => {
// Settle on end OR on the mid-body abort/error, so the dropped connection
// can't hang the test. What matters is that the proxy did NOT replay the
// request (no duplicate job): the upstream must see exactly 1 call.
await new Promise((resolve) => {
const req = http.request(
{
host: '127.0.0.1',
port,
path: '/api/analyze',
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': '12',
authorization: TEST_BEARER,
},
},
(res) => {
res.on('data', () => {});
res.on('end', resolve);
res.on('aborted', resolve);
res.on('error', resolve);
},
);
req.on('error', resolve);
req.write('{"repo":"x"}');
req.end();
});
// Give any (erroneous) retry a chance to fire before asserting.
await new Promise((r) => setTimeout(r, 300));
assert.equal(ctx.calls, 1, 'must not replay once the response body has started');
});
});
it('does NOT buffer or retry a body larger than the retry cap', async () => {
// Tiny cap so a modest body exceeds it and is streamed, not buffered.
const env = { GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES: '16' };
const bigBody = 'x'.repeat(1024);
await withProxy({ env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/analyze/upload', {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body: bigBody,
});
assert.equal(res.status, 200, 'over-cap body is streamed straight through');
assert.equal(ctx.body.length, bigBody.length, 'full body reaches upstream (not capped)');
});
});
it('returns 400 when the client declares a body but never finishes sending it', async () => {
// A live upstream, so a failure to reach it can't be mistaken for the body
// timeout. It must see zero requests: the proxy never connects because the
// buffering read times out first. The dedicated knob is set (leaving the
// upstream idle timeout at its default) to prove the two tune independently.
const env = { GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS: '300' };
await withProxy({ env }, async (port, ctx) => {
// Raw socket (not http.request, which would auto-finish the body): send a
// Content-Length: 100 request but only 10 bytes, then hold the socket open.
// We never close our side — the proxy must close it for us once the body
// read times out (via `Connection: close`), rather than holding the
// half-open connection until the server requestTimeout reaps it.
const { status, serverClosed, raw } = await new Promise((resolve) => {
const sock = connect(port, '127.0.0.1', () => {
sock.write(
'POST /api/analyze HTTP/1.1\r\n' +
'Host: 127.0.0.1\r\n' +
'Content-Type: application/json\r\n' +
`Authorization: ${TEST_BEARER}\r\n` +
'Content-Length: 100\r\n' +
'\r\n' +
'x'.repeat(10), // fewer than 100 bytes, then stall
);
});
let buf = '';
let status = null;
// Fail-safe: if the proxy never closes on its own, report serverClosed
// false (so the assertion fails cleanly) instead of hanging the test.
const guard = setTimeout(() => {
sock.destroy();
resolve({ status, serverClosed: false, raw: buf });
}, 2000);
sock.setEncoding('utf8');
sock.on('data', (chunk) => {
buf += chunk;
if (status === null) {
const m = buf.split('\r\n', 1)[0].match(/^HTTP\/\d\.\d (\d{3})/);
if (m) status = Number(m[1]);
}
});
// The server closing its side (Connection: close) ends our socket; treat
// any teardown initiated by the server as "closed promptly".
sock.on('error', () => {}); // a reset may precede 'close'; swallow it
sock.on('close', () => {
clearTimeout(guard);
resolve({ status, serverClosed: true, raw: buf });
});
});
assert.equal(status, 400, 'stalled body read must be bounded and return 400, not hang');
assert.ok(
serverClosed,
'proxy must close the half-open connection promptly, not hold it until requestTimeout',
);
assert.match(
raw.toLowerCase(),
/connection: close/,
'the 400 for a stalled body must advertise Connection: close',
);
assert.equal(ctx.calls, 0, 'proxy must not connect upstream when the body never arrives');
});
});
// -- Token gate at the public edge (GITNEXUS_SERVE_AUTH_TOKEN) --------------
//
// The proxy terminates the browser Origin, so the API's own write guard can't
// see a cross-site request coming. The token replaces it, checked on the way in.
it('answers an /api/* request with no Authorization header with a well-formed 401', async () => {
await withProxy({}, async (port, ctx) => {
const res = await rawRequest(port, '/api/health');
assert.equal(res.status, 401);
assert.equal(res.headers['www-authenticate'], 'Bearer');
assert.match(res.headers['content-type'], /application\/json/);
// The UI dispatches on the stable code, not on message text.
assert.deepEqual(JSON.parse(res.body), { error: 'unauthorized', code: 'unauthorized' });
assert.equal(ctx.calls, 0, 'an unauthenticated request must cost nothing upstream');
});
});
it('closes the connection on a rejected request rather than draining its body', async () => {
// The 401 is answered before the body is read, so without Connection: close
// Node drains up to 64KB of an unauthenticated upload to keep the socket
// reusable. Same reasoning as the stalled-body 400 above.
await withProxy({}, async (port, ctx) => {
const res = await rawRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: '/etc' }),
});
assert.equal(res.status, 401);
assert.equal(res.headers.connection, 'close');
assert.equal(ctx.calls, 0);
});
});
it('rejects a wrong token of the same length', async () => {
await withProxy({}, async (port, ctx) => {
const wrong = 'x'.repeat(TEST_AUTH_TOKEN.length);
const res = await apiRequest(port, '/api/health', {
headers: { authorization: `Bearer ${wrong}` },
});
assert.equal(res.status, 401);
assert.equal(ctx.calls, 0);
});
});
it('rejects a wrong token of a different length', async () => {
// The unequal-length branch takes a different path through the comparison
// (dummy compare, no timingSafeEqual on the real buffers) and still must 401.
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/health', {
headers: { authorization: 'Bearer short' },
});
assert.equal(res.status, 401);
assert.equal(ctx.calls, 0);
});
});
it('rejects the raw token without the Bearer prefix', async () => {
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/health', {
headers: { authorization: TEST_AUTH_TOKEN },
});
assert.equal(res.status, 401);
assert.equal(ctx.calls, 0);
});
});
it('forwards an /api/* request that carries the correct token', async () => {
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/health', {
headers: { authorization: TEST_BEARER },
});
assert.equal(res.status, 200);
assert.equal(ctx.calls, 1);
});
});
it('strips the Authorization header instead of forwarding the edge token', async () => {
// The token is spent at this hop. `serve` reads no Authorization header, so
// forwarding would only copy a live credential into another service's logs.
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
assert.equal(res.status, 200, 'the request itself must still be proxied');
assert.equal(ctx.received.headers.authorization, undefined);
});
});
it('never gates static assets behind the token', async () => {
// The UI has to load before it can prompt for a token.
await withProxy({}, async (port, ctx) => {
for (const path of ['/', '/index.html', '/some/app/route']) {
const res = await rawRequest(port, path);
assert.equal(res.status, 200, `${path} must be served without a token`);
assert.match(res.body, /spa/);
}
assert.equal(ctx.calls, 0);
});
});
// Run docker-server.mjs to completion and report how it exited. Used for the
// boot-time refusal, which never reaches a listening state.
function runUntilExit(cwd, env) {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, [serverScript], {
cwd,
env: { ...process.env, ...env },
stdio: 'pipe',
});
let stderr = '';
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => {
stderr += chunk;
});
proc.on('error', reject);
proc.on('exit', (code) => resolve({ code, stderr }));
// A server that starts instead of refusing never exits, so name that failure
// here rather than letting it surface as a timeout or a null exit code.
setTimeout(() => {
proc.kill();
reject(new Error('docker-server.mjs kept running; it was expected to refuse and exit'));
}, 5000).unref();
});
}
async function withDistDir(fn) {
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-boot-'));
await mkdir(join(dir, 'dist'), { recursive: true });
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
try {
await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
it('refuses to start when the proxy is enabled without a token', async () => {
await withDistDir(async (dir) => {
const port = await getFreePort();
const { code, stderr } = await runUntilExit(dir, {
PORT: String(port),
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
GITNEXUS_SERVE_AUTH_TOKEN: undefined,
});
assert.equal(code, 1, 'an unauthenticated public proxy must fail closed at boot');
assert.match(stderr, /Refusing to start/);
assert.match(stderr, /GITNEXUS_SERVE_AUTH_TOKEN/);
});
});
it('refuses to start when the token is short enough to guess', async () => {
// Nothing rate-limits a failed token, so a weak one is guessable at network
// speed. The floor is what makes the missing limiter safe.
await withDistDir(async (dir) => {
const port = await getFreePort();
const { code, stderr } = await runUntilExit(dir, {
PORT: String(port),
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
GITNEXUS_SERVE_AUTH_TOKEN: 'hunter2',
});
assert.equal(code, 1);
assert.match(stderr, /shorter than 32 characters/);
assert.ok(!stderr.includes('hunter2'), 'the refusal must never echo the token');
});
});
it('treats a whitespace-only token as absent rather than as a short one', async () => {
// ' ' trims to empty, so this must hit the missing-token refusal, not the
// length one.
await withDistDir(async (dir) => {
const port = await getFreePort();
const { code, stderr } = await runUntilExit(dir, {
PORT: String(port),
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
GITNEXUS_SERVE_AUTH_TOKEN: ' ',
});
assert.equal(code, 1);
assert.match(stderr, /is set without GITNEXUS_SERVE_AUTH_TOKEN/);
});
});
it('starts normally with neither the proxy nor a token configured', async () => {
// docker-compose's default: static assets only, nothing to gate, no refusal.
await withDistDir(async (dir) => {
const port = await getFreePort();
const proc = spawnServerWithEnv(dir, port, { GITNEXUS_SERVE_AUTH_TOKEN: undefined });
try {
await waitForServer(port);
const res = await rawRequest(port, '/');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
} finally {
await killAndWait(proc);
}
});
});