From f2717c6a7c44ea61f1e0b0e5ce3404751ee1f6dc Mon Sep 17 00:00:00 2001 From: Shifra Williams Date: Wed, 5 Aug 2026 17:19:44 -0700 Subject: [PATCH] feat(render): add one-click deploy to render support (#2804) --- Dockerfile.cli | 6 +- README.md | 22 + SECURITY.md | 13 + docker-server.mjs | 446 +++++++++- docker-server.test.mjs | 823 ++++++++++++++++++ .../src/components/AccessTokenPrompt.tsx | 67 ++ gitnexus-web/src/components/DropZone.tsx | 16 +- gitnexus-web/src/components/SettingsPanel.tsx | 28 + .../src/components/settings/SecretInput.tsx | 56 ++ gitnexus-web/src/config/ui-constants.ts | 15 + gitnexus-web/src/hooks/useAppState.tsx | 4 +- gitnexus-web/src/hooks/useBackend.ts | 18 +- gitnexus-web/src/i18n/error-messages.ts | 2 + gitnexus-web/src/locales/en/errors.json | 1 + gitnexus-web/src/locales/en/settings.json | 11 + gitnexus-web/src/locales/zh-CN/errors.json | 1 + gitnexus-web/src/locales/zh-CN/settings.json | 11 + gitnexus-web/src/services/backend-client.ts | 245 ++++-- .../test/unit/access-token-prompt.test.tsx | 60 ++ .../test/unit/backend-client-auth.test.ts | 239 +++++ gitnexus-web/test/unit/heartbeat.test.ts | 327 ++++--- .../test/unit/settings-panel-token.test.tsx | 49 ++ gitnexus/test/unit/render-blueprint.test.ts | 144 +++ render.yaml | 81 ++ 24 files changed, 2482 insertions(+), 203 deletions(-) create mode 100644 gitnexus-web/src/components/AccessTokenPrompt.tsx create mode 100644 gitnexus-web/src/components/settings/SecretInput.tsx create mode 100644 gitnexus-web/test/unit/access-token-prompt.test.tsx create mode 100644 gitnexus-web/test/unit/backend-client-auth.test.ts create mode 100644 gitnexus-web/test/unit/settings-panel-token.test.tsx create mode 100644 gitnexus/test/unit/render-blueprint.test.ts create mode 100644 render.yaml diff --git a/Dockerfile.cli b/Dockerfile.cli index 633d23f5d..b42c22dad 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -125,5 +125,7 @@ ENV GITNEXUS_HOME=/data/gitnexus \ EXPOSE 4747 -# Bind to 0.0.0.0 so the server is reachable from the host's mapped port. -CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"] +# Bind 0.0.0.0 for the host's mapped port, honoring an injected $PORT (Render +# sets one). `sh -c` expands it; `exec` keeps the server PID 1 so SIGTERM still +# reaches it. Platforms can rely on this instead of a dockerCommand override. +CMD ["sh", "-c", "exec gitnexus serve --host 0.0.0.0 --port \"${PORT:-4747}\""] diff --git a/README.md b/README.md index 7158cbd66..d6534f658 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,28 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau +### Deploy to Render + +Deploy GitNexus in one click: + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus) + +The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin. + +At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans. + +The deploy generates an access token, and the UI asks for it on first use: + +1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/). +2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab. +3. Load the site and paste the token into the prompt (or the settings panel). + +Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy. + +The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render). + +Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes. + ## Two Ways to Use GitNexus | | **CLI + MCP** (recommended) | **Web UI** | diff --git a/SECURITY.md b/SECURITY.md index 79ef97f6b..d1fbcd051 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you - **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below. - **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place. +### Hosted Deploys on Render + +The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect: + +- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret. +- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying. +- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard. +- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories. +- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover. +- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in. + +Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed. + ## Automated Scans Running in CI This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab. diff --git a/docker-server.mjs b/docker-server.mjs index f6c9eb8f6..e3e9f68ee 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -1,5 +1,8 @@ +import { timingSafeEqual } from 'node:crypto'; +import { writeSync } from 'node:fs'; import { open } from 'node:fs/promises'; -import { createServer } from 'node:http'; +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'; @@ -22,18 +25,430 @@ function jsonForScriptTag(obj) { .replace(/&/g, '\\u0026'); } -const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null; -if (rawBackendUrl && !isValidUrl(rawBackendUrl)) { - const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200); - console.warn( - `[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`, +// 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) ); } -const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null; + +// 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', @@ -68,6 +483,23 @@ 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); diff --git a/docker-server.test.mjs b/docker-server.test.mjs index ee3a4301a..80e742f7e 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -1,4 +1,5 @@ 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'; @@ -263,3 +264,825 @@ it('does not inject config into static assets', async () => { 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'), 'spa'); + + 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 on the parsed value, not a substring of the page: a bare + // includes() would also pass if the URL appeared in a comment. + const injected = /window\.__GITNEXUS_CONFIG__=(\{.*?\});/.exec(res.body)?.[1]; + assert.ok(injected, 'Expected __GITNEXUS_CONFIG__ in response body'); + assert.equal(JSON.parse(injected).backendUrl, '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'), 'spa'); + 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); + } + }); +}); diff --git a/gitnexus-web/src/components/AccessTokenPrompt.tsx b/gitnexus-web/src/components/AccessTokenPrompt.tsx new file mode 100644 index 000000000..a903e9efc --- /dev/null +++ b/gitnexus-web/src/components/AccessTokenPrompt.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Key } from '@/lib/lucide-icons'; +import { useTranslation } from 'react-i18next'; +import { getAuthToken, setAuthToken } from '../services/backend-client'; +import { SecretInput } from './settings/SecretInput'; + +interface AccessTokenPromptProps { + /** Called after the token is stored, so the caller can re-probe immediately. */ + onSubmit?: () => void; +} + +/** + * Shown instead of the "start a server" guide when the backend answers 401: + * the deploy is up, it just needs the access token its operator generated. + * + * The token is held in sessionStorage for this browser session only — see + * AUTH_TOKEN_STORAGE_KEY. Nothing here logs it or puts it in a URL. + */ +export const AccessTokenPrompt = ({ onSubmit }: AccessTokenPromptProps) => { + const { t } = useTranslation('settings'); + const [token, setToken] = useState(getAuthToken); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setAuthToken(token); + onSubmit?.(); + }; + + return ( +
+
+
+ +
+

+ {t('accessToken.title')} +

+

+ {t('accessToken.promptHint')} +

+
+ + + + + +

+ {t('accessToken.sessionNote')} +

+ + ); +}; diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index 389f99869..8fd8b6621 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -7,6 +7,7 @@ import { type BackendRepo, } from '../services/backend-client'; import { useBackend } from '../hooks/useBackend'; +import { AccessTokenPrompt } from './AccessTokenPrompt'; import { OnboardingGuide } from './OnboardingGuide'; import { AnalyzeOnboarding } from './AnalyzeOnboarding'; import { RepoLanding } from './RepoLanding'; @@ -147,6 +148,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { const { isConnected, isProbing, + isUnauthorized, startPolling, stopPolling, isPolling, @@ -310,8 +312,20 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { )} + {/* The backend is up but gated — asking for a token is the only useful + thing to show. The "run gitnexus serve" guide would be wrong advice. */} + {isUnauthorized && !isConnected && ( + { + // The polling chain is already running while disconnected; it + // picks up the new token on its next tick and auto-connects. + if (!isPolling) startPolling(); + }} + /> + )} + {/* Crossfade between phases */} - {displayPhase && ( + {!isUnauthorized && displayPhase && ( {displayPhase === 'onboarding' && } {displayPhase === 'analyze' && } diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index e6b933e49..0c3a22aec 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -20,9 +20,11 @@ import { getAvailableModels, fetchOpenRouterModels, } from '../core/llm/settings-service'; +import { getAuthToken, setAuthToken } from '../services/backend-client'; import type { LLMSettings, LLMProvider } from '../core/llm/types'; import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants'; import { ProviderConfigCard } from './settings/ProviderConfigCard'; +import { SecretInput } from './settings/SecretInput'; import { useTranslation } from 'react-i18next'; interface SettingsPanelProps { @@ -253,6 +255,8 @@ export const SettingsPanel = ({ const { t } = useTranslation(['common', 'settings']); const [settings, setSettings] = useState(loadSettings); const [showApiKey, setShowApiKey] = useState>({}); + /** Deploy access token. Stored outside LLM settings, persisted on Save. */ + const [authToken, setAuthTokenState] = useState(getAuthToken); const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); const saveTimerRef = useRef>(undefined); // Ollama connection state @@ -275,6 +279,7 @@ export const SettingsPanel = ({ useEffect(() => { if (isOpen) { setSettings(loadSettings()); + setAuthTokenState(getAuthToken()); setSaveStatus('idle'); setOllamaError(null); } @@ -315,6 +320,10 @@ export const SettingsPanel = ({ const handleSave = () => { try { saveSettings(settings); + // The token persists on Save with everything else, not per keystroke: it + // is the only affordance this panel gives for "committed", and a + // half-typed token would otherwise ride the next probe. + setAuthToken(authToken); setSaveStatus('saved'); onSettingsSaved?.(); if (saveTimerRef.current) { @@ -372,6 +381,25 @@ export const SettingsPanel = ({ {/* Content */}
+ {/* Deploy access token. Rendered unconditionally, unlike the Local + Server block below, which only appears when a caller passes the + backend-URL props. An empty token is a valid state — a local + `gitnexus serve` or `docker compose` deploy has no gate. */} +
+ + +

{t('settings:accessToken.hint')}

+
+ {/* Local Server */} {backendUrl !== undefined && onBackendUrlChange && (
diff --git a/gitnexus-web/src/components/settings/SecretInput.tsx b/gitnexus-web/src/components/settings/SecretInput.tsx new file mode 100644 index 000000000..67cb3f9ce --- /dev/null +++ b/gitnexus-web/src/components/settings/SecretInput.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { Eye, EyeOff } from '@/lib/lucide-icons'; + +interface SecretInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + /** Accessible name for the field — the visible `