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, '\\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) // The line-break strip is redundant with the range below, but CodeQL's // js/log-injection recognizes only this shape as a sanitizer: a global // replace of a literal \n with the empty string. .replace(/\n/g, '') .replace(/\r/g, '') .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 ? `` : ''; // 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 ${sanitizeForLog(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:', sanitizeForLog(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:', sanitizeForLog(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('')) { console.warn('[gitnexus-web] Could not inject config: no tag found in HTML'); } const html = raw.includes('') ? raw.replace('', `${configScript}`) : 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}`); });