GitNexus/docker-server.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

600 lines
23 KiB
JavaScript

import { timingSafeEqual } from 'node:crypto';
import { writeSync } from 'node:fs';
import { open } from 'node:fs/promises';
import { createServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
const host = '0.0.0.0';
const port = Number(process.env.PORT || '4173');
const root = resolve(process.cwd(), 'dist');
function isValidUrl(value) {
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function jsonForScriptTag(obj) {
return JSON.stringify(obj)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
// Warnings echo operator input back, so strip control characters (log forging)
// and cap the length first.
function sanitizeForLog(value) {
return String(value)
.replace(/[\x00-\x1f\x7f]/g, ' ')
.slice(0, 200);
}
// console.error is asynchronous when stderr is a pipe, so pairing it with
// process.exit can drop the one message explaining the refusal. writeSync isn't.
function exitWithRefusal(message) {
writeSync(2, `${message}\n`);
process.exit(1);
}
// `value` if it's a usable http/https URL, else null + a warning naming `label`.
// `rawForLog` lets a caller that normalized first echo back the operator's input.
function validHttpUrl(label, value, rawForLog = value) {
if (!value) return null;
if (isValidUrl(value)) return value;
const safeRaw = sanitizeForLog(rawForLog);
console.warn(`[gitnexus-web] ${label} "${safeRaw}" is not a valid http/https URL -- ignoring.`);
return null;
}
// Numeric env var. Every consumer below reads <= 0 as "disabled", so obeying a
// typo like -1 would switch a timeout off silently. Warn and use the default.
function numberFromEnv(label, fallback, min = 0) {
const raw = process.env[label];
if (raw === undefined || raw === '') return fallback;
const n = Number(raw);
if (!Number.isFinite(n)) {
console.warn(
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is not a number -- using ${fallback}.`,
);
return fallback;
}
if (n < min) {
console.warn(
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is below the minimum ${min} -- using ${fallback}.`,
);
return fallback;
}
return n;
}
// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser
// its own public origin — same-origin API calls via the proxy below, no config.
const backendUrlVar =
process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL';
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null;
const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl);
const configScript = backendUrl
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
: '';
// Optional same-origin reverse proxy for the API server. On a split deploy
// (public web service, private API) the browser must reach the API without a
// cross-origin request, since its CORS allowlist and write-route guard only
// admit same-host origins. So the browser targets THIS origin and we forward
// /api/* to GITNEXUS_UPSTREAM_URL. Unset → no proxy (docker-compose default).
// A scheme-less host:port — what Render's `fromService: hostport` yields —
// gets http:// prepended.
const rawUpstream = process.env.GITNEXUS_UPSTREAM_URL;
const rawUpstreamUrl = rawUpstream
? /^https?:\/\//.test(rawUpstream)
? rawUpstream
: `http://${rawUpstream}`
: null;
const upstreamBase = validHttpUrl('GITNEXUS_UPSTREAM_URL', rawUpstreamUrl, rawUpstream);
// The one origin this proxy will ever connect to (see proxyToUpstream).
const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
// The Bearer token every /api/* request must carry. The private upstream has no
// auth of its own and loses its Origin guard one hop below (see
// proxyToUpstream), so the gate belongs here. The browser holds it — never
// inject it next to `backendUrl`. Blank-is-absent follows resolveAuthToken
// (gitnexus/src/mcp/http-transport.ts).
const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null;
// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer),
// relocated because the trust boundary is here: an unguarded `serve` behind a
// private service is legitimate, an unguarded public proxy is not.
if (upstreamBase && !authToken) {
exitWithRefusal(
'[gitnexus-web] Refusing to start: GITNEXUS_UPSTREAM_URL is set without ' +
'GITNEXUS_SERVE_AUTH_TOKEN. The proxy would expose every indexed repo — ' +
'index, read source, and delete — to anyone with this URL. Set a token, ' +
'or unset GITNEXUS_UPSTREAM_URL to serve static assets only.',
);
}
// Rejected requests never reach the upstream limiter, so guesses are free. A
// throttle would add per-address state to a stateless proxy and a lockout an
// attacker can aim at a real user; a length floor makes guessing hopeless and
// only ever rejects a hand-picked token.
const MIN_AUTH_TOKEN_LENGTH = 32;
if (authToken && authToken.length < MIN_AUTH_TOKEN_LENGTH) {
exitWithRefusal(
`[gitnexus-web] Refusing to start: GITNEXUS_SERVE_AUTH_TOKEN is shorter than ` +
`${MIN_AUTH_TOKEN_LENGTH} characters. It is the only thing standing between the ` +
'public internet and every indexed repo, and a failed guess is not rate-limited. ' +
'Use a generated random value.',
);
}
// Whether an inbound X-Forwarded-For may be believed (see clientAddressFor).
// Default off, so a wrong deployment fails toward over-restriction rather than
// toward an address the caller picks. `true` is rejected as it is server-side
// (resolveTrustProxy, which also takes hop counts and so rejects `yes`/`on`
// too): it reads as "trust the whole chain".
function resolveTrustXff(raw) {
const value = raw?.trim();
if (!value) return false;
if (/^(1|yes|on)$/i.test(value)) return true;
if (/^(0|no|off|false)$/i.test(value)) return false;
console.warn(
`[gitnexus-web] GITNEXUS_PROXY_TRUST_XFF "${sanitizeForLog(value)}" is not a recognized ` +
'boolean -- ignoring the inbound X-Forwarded-For chain. Set 1 only when a load balancer ' +
'that appends the real peer sits in front of this service.',
);
return false;
}
const trustInboundXff = resolveTrustXff(process.env.GITNEXUS_PROXY_TRUST_XFF);
// Idle timeout for a proxied request → 504. Socket activity (SSE heartbeats)
// resets it, so long-lived streams are unaffected. 0 disables.
const proxyTimeoutMs = numberFromEnv('GITNEXUS_PROXY_TIMEOUT_MS', 120000);
// nginx's client_body_timeout equivalent: how long to wait for a replayable
// client body before 400. Defaults to the idle timeout; 0 disables.
const proxyClientBodyTimeoutMs = numberFromEnv(
'GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS',
proxyTimeoutMs,
);
// Bounded connection-retry, to ride out the few-second window where a
// single-instance upstream (private server + disk ⇒ no zero-downtime deploy)
// is restarting. Attempts of 1 disables it, and body buffering with it.
const proxyRetryAttempts = numberFromEnv('GITNEXUS_PROXY_RETRY_ATTEMPTS', 3, 1);
const proxyRetryEnabled = proxyRetryAttempts > 1;
const proxyRetryMaxBodyBytes = numberFromEnv('GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES', 256 * 1024);
// Never connected ⇒ the upstream got nothing ⇒ safe to replay any method.
const preConnectRetryCodes = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
// Failed after connecting ⇒ the upstream may already be working on it, so
// replay only idempotent methods (RFC 7231 §4.2.2) to avoid double-execution.
const postConnectRetryCodes = new Set(['ECONNRESET', 'ETIMEDOUT']);
const idempotentMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']);
// Buffer a request body, capped. Resolves null on overflow, client error, or
// timeout — one "unreadable body" contract, which the caller maps to 400.
// Listeners detach once settled so a later pipe of the same request is clean.
function readBodyCapped(req, cap, timeoutMs) {
return new Promise((resolvePromise) => {
const chunks = [];
let total = 0;
let settled = false;
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
req.removeListener('data', onData);
req.removeListener('end', onEnd);
req.removeListener('error', onError);
};
const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
resolvePromise(value);
};
const onData = (chunk) => {
total += chunk.length;
if (total > cap) {
finish(null);
return;
}
chunks.push(chunk);
};
const onEnd = () => finish(Buffer.concat(chunks));
const onError = () => finish(null);
req.on('data', onData);
req.on('end', onEnd);
req.on('error', onError);
// Hard cap regardless of idle activity; Node's requestTimeout is the outer
// backstop.
if (timeoutMs > 0) {
timer = setTimeout(() => {
console.warn(`[gitnexus-web] client body read timed out after ${timeoutMs}ms`);
finish(null);
}, timeoutMs);
}
});
}
// Constant-time Bearer check, mirroring createAuthMiddleware in
// gitnexus/src/mcp/http-transport.ts — dummy comparison included, so an absent
// or wrong-length header costs the same and the timing can't leak the length.
// Duplicated because this file is plain ESM and can't import from gitnexus/src.
function authorized(req) {
if (!authToken) return true; // static-only: no proxy, nothing to gate
const header = req.headers['authorization'];
const expected = Buffer.from(`Bearer ${authToken}`);
if (typeof header !== 'string') {
timingSafeEqual(Buffer.alloc(expected.length), expected);
return false;
}
const provided = Buffer.from(header);
if (provided.length !== expected.length) {
timingSafeEqual(Buffer.alloc(expected.length), expected);
return false;
}
return timingSafeEqual(provided, expected);
}
// WWW-Authenticate names the scheme; the stable `code` is what the web client
// dispatches on, not message text. The body must not distinguish "no token
// configured" from "wrong token". `Connection: close` because we answer before
// reading the body, which Node would otherwise drain (as with the 400 below).
function sendUnauthorized(res) {
const body = JSON.stringify({ error: 'unauthorized', code: 'unauthorized' });
res.writeHead(401, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'WWW-Authenticate': 'Bearer',
Connection: 'close',
});
res.end(body);
}
// Fail a proxied request. Once headers are sent the body is partially written
// and can't be replaced, so the socket is all we can destroy.
function failGateway(res, status, message) {
if (res.headersSent) {
res.destroy();
} else {
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(message);
}
}
// Hop-by-hop headers (RFC 7230 §6.1) describe one connection, so a proxy must
// not forward them in either direction; Node sets its own per hop.
const hopByHopHeaders = [
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
];
function stripHopByHopHeaders(headers) {
// §6.1 also lets `Connection` name additional single-hop headers, which the
// fixed list below can't cover. Node lowercases header keys on both the
// server and client side, so a lowercased name indexes `headers` directly.
for (const listed of String(headers.connection ?? '').split(',')) {
const name = listed.trim().toLowerCase();
if (name) delete headers[name];
}
for (const name of hopByHopHeaders) delete headers[name];
return headers;
}
// The client address this proxy vouches for upstream. The API keys its rate
// limiter off req.ip, so forwarding a client-supplied X-Forwarded-For would let
// anyone rotate a fake address per request. Which entry is real depends on a
// deployment fact this process can't observe (is anything in front appending the
// peer?), so the operator asserts it via GITNEXUS_PROXY_TRUST_XFF; until then we
// forward the socket peer.
function clientAddressFor(req) {
if (!trustInboundXff) return req.socket.remoteAddress || null;
const forwarded = String(req.headers['x-forwarded-for'] ?? '')
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.pop();
return forwarded || req.socket.remoteAddress || null;
}
// Forward an `/api/*` request upstream, streaming both bodies (SSE / chunked
// graph streams) untouched. Retries connect failures when the body is replayable.
async function proxyToUpstream(req, res) {
let upstream;
try {
upstream = new URL(req.url, upstreamBase);
} catch {
res.writeHead(400);
res.end('Bad request');
return;
}
// The `/api/` route guard keeps req.url host-relative, so resolution can't
// leave upstreamBase. Asserting it here means the SSRF boundary doesn't rest
// on that two-step argument: one legitimate destination, checked locally.
if (upstream.origin !== upstreamOrigin) {
console.error(`[gitnexus-web] refusing to proxy off-origin target ${upstream.origin}`);
res.writeHead(400);
res.end('Bad request');
return;
}
const isHttps = upstream.protocol === 'https:';
const requestFn = isHttps ? httpsRequest : httpRequest;
const headers = stripHopByHopHeaders({ ...req.headers });
// Terminate the browser origin: the API admits Origin-less requests as
// trusted server-to-server calls. Nothing is lost — the browser only ever
// talks to this same-origin web service.
delete headers.origin;
delete headers.referer;
// The edge token is spent here. `serve` reads no Authorization header
// (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding
// it would only copy a live credential into another service's logs. Pinned by
// test.
delete headers.authorization;
headers.host = upstream.host;
// Replace, never forward, the inbound chain (see clientAddressFor).
const clientAddress = clientAddressFor(req);
if (clientAddress) headers['x-forwarded-for'] = clientAddress;
else delete headers['x-forwarded-for'];
// A retry replays the body, so buffer it up front — but only when small and
// of known length. Larger/unknown bodies (multipart uploads) stream once with
// no retry; an upload is never buffered.
const method = (req.method || 'GET').toUpperCase();
const isIdempotentMethod = idempotentMethods.has(method);
// A request has a body iff it frames one (RFC 7230 §3.3.3). Keying off the
// method sends a bodyless DELETE down the stream-once path and gives up a
// replay that costs nothing.
const hasBody =
req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined;
const len = Number(req.headers['content-length']);
const bufferable =
proxyRetryEnabled && Number.isFinite(len) && len >= 0 && len <= proxyRetryMaxBodyBytes;
let bodyBuf = hasBody ? null : Buffer.alloc(0);
if (hasBody && bufferable) {
bodyBuf = await readBodyCapped(req, proxyRetryMaxBodyBytes, proxyClientBodyTimeoutMs);
if (bodyBuf === null) {
// Overflow, client error, and timeout all collapse to 400 (not 413/408).
// `Connection: close` lets Node drop the socket after the 400 flushes,
// rather than half-open draining a stalled upload until requestTimeout.
if (!res.headersSent) {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
Connection: 'close',
});
res.end('Bad request');
}
return;
}
}
// bodyBuf === null means "stream the live request once, no retry".
const retryEligible = bodyBuf !== null;
const attempt = (n) => {
let timedOut = false;
const upstreamReq = requestFn(
{
protocol: upstream.protocol,
hostname: upstream.hostname,
port: upstream.port || (isHttps ? 443 : 80),
method: req.method,
path: upstream.pathname + upstream.search,
headers,
},
(upstreamRes) => {
// Pipe rather than buffer, so SSE / chunked streams reach the browser
// incrementally. Node re-derives Transfer-Encoding for this hop.
const responseHeaders = stripHopByHopHeaders({ ...upstreamRes.headers });
res.writeHead(upstreamRes.statusCode || 502, responseHeaders);
upstreamRes.on('error', () => res.destroy());
upstreamRes.pipe(res);
},
);
upstreamReq.on('error', (err) => {
if (timedOut) return; // 504 already sent by the timeout handler below
// Only before any response byte reaches the browser — once headers are
// sent the body is partially written and can't be replayed.
const retryableError =
preConnectRetryCodes.has(err.code) ||
(isIdempotentMethod && postConnectRetryCodes.has(err.code));
if (retryEligible && !res.headersSent && n < proxyRetryAttempts && retryableError) {
const delay = 250 * 2 ** (n - 1); // 250ms, 500ms, ...
console.warn(
`[gitnexus-web] upstream ${err.code}; retry ${n}/${proxyRetryAttempts - 1} in ${delay}ms`,
);
setTimeout(() => {
// The client may have aborted during the backoff window; don't fire a
// fresh upstream request nobody is waiting for anymore.
if (res.writableEnded || res.destroyed) return;
attempt(n + 1);
}, delay);
return;
}
console.error('[gitnexus-web] upstream proxy error:', err.message);
failGateway(res, 502, 'Bad gateway');
});
if (proxyTimeoutMs > 0) {
upstreamReq.setTimeout(proxyTimeoutMs, () => {
timedOut = true;
console.error(`[gitnexus-web] upstream proxy timeout after ${proxyTimeoutMs}ms`);
failGateway(res, 504, 'Gateway timeout');
upstreamReq.destroy();
});
}
if (bodyBuf !== null) {
// Replayable body already buffered; write it fresh on each attempt.
if (bodyBuf.length) upstreamReq.write(bodyBuf);
upstreamReq.end();
} else {
// Non-retryable: stream the live request once.
req.on('error', () => upstreamReq.destroy());
req.pipe(upstreamReq);
}
};
attempt(1);
}
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
// Static asset server for the gitnexus-web Docker image.
//
// TOCTOU prevention: every filesystem interaction uses open() to get a
// file handle; subsequent reads use handle.readFile()/createReadStream().
//
// CodeQL js/file-system-race: the query pairs open() calls when their
// path arguments are data-flow aliased. This handler uses exactly two
// open() calls whose paths are provably independent:
// 1. open(requestedPath) — derived from the URL
// 2. open(spaFallback) — the constant root/index.html
// Because spaFallback has no data-flow from the request, CodeQL cannot
// pair them as a check/use on the same path.
//
// Path-injection containment: each open() is preceded by a
// path.relative() barrier that CodeQL recognizes as a sanitizer.
const spaFallback = resolve(root, 'index.html');
const server = createServer(async (req, res) => {
const urlPath = req.url?.split('?')[0] || '/';
// Same-origin API proxy; everything else falls through to the SPA below.
if (upstreamBase && (urlPath === '/api' || urlPath.startsWith('/api/'))) {
// Before body buffering and the upstream socket, so an unauthenticated
// request costs nothing upstream. Static assets are never gated: the UI has
// to load in order to prompt for the token.
if (!authorized(req)) {
sendUnauthorized(res);
return;
}
// Fire-and-forget, so guard the boundary against unhandledRejection.
proxyToUpstream(req, res).catch((err) => {
console.error('[gitnexus-web] proxy handler crashed:', err?.message ?? err);
failGateway(res, 502, 'Bad gateway');
});
return;
}
let decoded;
try {
decoded = decodeURIComponent(urlPath);
} catch {
res.writeHead(400);
res.end('Bad request');
return;
}
if (decoded.includes('\0')) {
res.writeHead(400);
res.end('Bad request');
return;
}
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
const requestedPath = resolve(root, cleanPath);
const rel = relative(root, requestedPath);
if (rel.startsWith('..') || isAbsolute(rel)) {
res.writeHead(400);
res.end('Bad request');
return;
}
let handle;
try {
let servePath = requestedPath;
// Try to open the exact path the client asked for.
handle = await open(requestedPath, 'r').catch(() => null);
if (handle) {
const s = await handle.stat();
if (!s.isFile()) {
// Directories and other non-files fall through to SPA fallback.
await handle.close();
handle = null;
}
}
// If the requested path wasn't a regular file, serve the SPA entry
// point. spaFallback is a module-level constant with no data-flow
// from the request, so this open() is independent of the one above.
if (!handle) {
servePath = spaFallback;
handle = await open(spaFallback, 'r').catch(() => null);
if (!handle) {
res.writeHead(404);
res.end('Not found');
return;
}
const s = await handle.stat();
if (!s.isFile()) {
res.writeHead(404);
res.end('Not found');
return;
}
}
const isHtml = extname(servePath) === '.html' || !extname(servePath);
const cacheControl = servePath.includes(`${sep}assets${sep}`)
? 'public, max-age=31536000, immutable'
: 'no-cache';
const contentType = contentTypes[extname(servePath)] || 'application/octet-stream';
if (isHtml && configScript) {
const raw = await handle.readFile('utf8');
await handle.close();
handle = null;
if (!raw.includes('</head>')) {
console.warn('[gitnexus-web] Could not inject config: no </head> tag found in HTML');
}
const html = raw.includes('</head>') ? raw.replace('</head>', `${configScript}</head>`) : raw;
const buf = Buffer.from(html, 'utf8');
res.writeHead(200, {
'Cache-Control': cacheControl,
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': buf.length,
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
res.end(buf);
} else {
res.writeHead(200, {
'Cache-Control': cacheControl,
'Content-Type': contentType,
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
const stream = handle.createReadStream();
handle = null;
stream.on('error', () => res.destroy());
stream.pipe(res);
}
} catch (error) {
console.error(error);
res.writeHead(500);
res.end('Internal server error');
} finally {
if (handle) await handle.close().catch(() => {});
}
});
server.listen(port, host, () => {
console.log(`gitnexus-web listening on http://${host}:${port}`);
});