feat(serve): validate and port-scope the origin/proxy configuration surface (#2820)

This commit is contained in:
Shifra Williams 2026-08-04 22:52:39 -07:00 committed by GitHub
parent f36c3eb678
commit a6a8aa788c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1268 additions and 96 deletions

View file

@ -73,6 +73,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
- [ ] No secrets, tokens, or machine-specific paths committed.
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
- [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it.
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only).
## Code review

View file

@ -508,6 +508,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
</details>

View file

@ -45,3 +45,39 @@
# integer only — non-integer or < 1 values fall back to 32.
# See README § "Scope-resolution dispatch-target cap".
# GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64
# `serve` behind a reverse proxy
# Only needed when `gitnexus serve` is reached through a proxy, or bound to a
# wildcard address. Leave both unset for a local install.
# The single browser origin this server is reached through. A wildcard bind has
# no host identity, so without this the write-route guard rejects the UI's
# writes.
#
# NOT USABLE YET: `serve` has no authentication, and requests with no Origin
# header (curl, any script) already reach POST /api/analyze and DELETE /api/repo,
# so setting this makes `serve` refuse to start rather than open a public bind
# with nothing behind it.
#
# Matching rules for when the gate lifts: the hostname must match exactly, and so
# must the scheme. A value with no scheme means https, so plain HTTP needs the
# explicit http:// form. An explicit port must match; with no port, any port on
# that hostname is accepted. Anything that is not one reachable host (a list, a
# wildcard, a bare port number, a :0 port, a trailing dot) warns and allows
# nothing.
# GITNEXUS_PUBLIC_ORIGIN=https://gitnexus.example.com
# Which upstream hops may set X-Forwarded-*, so the per-IP rate limiter keys on
# the client IP and not the proxy's. Set it to the exact number of proxies you
# control. Every hop past that is one more entry of the chain the caller gets to
# write. false/no/off (and a 0 hop count) trust no hop; a proxy list Express can
# compile ("loopback", "10.0.0.0/8, 127.0.0.1") names them instead.
# true/yes/on is REJECTED: it reads the client-controlled leftmost
# X-Forwarded-For entry, so a spoofed chain earns a fresh rate-limit key per
# request, and express-rate-limit rejects it too. Counts above 16 are rejected as
# well, as a sanity ceiling rather than a safety boundary. Any invalid value warns
# and falls back to "loopback, linklocal, uniquelocal".
# Bind non-loopback with this unset and `serve` warns: a load balancer outside the
# private ranges is untrusted, so every request keys to the balancer and the
# per-IP limit becomes one shared limit.
# GITNEXUS_TRUST_PROXY=1

View file

@ -33,6 +33,7 @@
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"proxy-addr": "^2.0.7",
"tree-sitter": "0.21.1",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
@ -59,6 +60,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
@ -1946,6 +1948,16 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/proxy-addr": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/proxy-addr/-/proxy-addr-2.0.3.tgz",
"integrity": "sha512-TgAHHO4tNG3HgLTUhB+hM4iwW6JUNeQHCLnF1DjaDA9c69PN+IasoFu2MYDhubFc+ZIw5c5t9DMtjvrD6R3Egg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",

View file

@ -79,6 +79,7 @@
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"proxy-addr": "^2.0.7",
"tree-sitter": "0.21.1",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
@ -106,6 +107,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",

View file

@ -63,7 +63,16 @@ import {
GITHUB_TOKEN_HOSTS,
} from './git-clone.js';
import { createAnalyzeUploadHandler } from './analyze-upload.js';
import { createLocalhostOriginGuard, normalizeBoundHost } from './middleware.js';
import {
assertServeAuthForPublicOrigin,
createPublicOriginMatcher,
createWriteOriginGuard,
logOriginPolicy,
PUBLIC_ORIGIN_ENV,
resolveTrustProxy,
TRUST_PROXY_ENV,
warnIfRateLimitKeysCollapse,
} from './middleware.js';
import { createLaunchAnalysisWorker } from './analyze-launch.js';
import { UPLOAD_ROOT } from './upload-paths.js';
import { sweepStaleUploads } from './upload-sweep.js';
@ -85,6 +94,8 @@ const pkg = _require('../../package.json');
* 172.16.0.0/12 172.16.x.x 172.31.x.x
* 192.168.0.0/16 192.168.x.x
* - https://gitnexus.vercel.app — the deployed GitNexus web UI
* - the origin named by GITNEXUS_PUBLIC_ORIGIN, when set matched on hostname
* always, and on scheme and port when the configured value carries them
*
* @param origin - The value of the HTTP `Origin` request header, or `undefined`
* when the header is absent (non-browser request).
@ -110,21 +121,22 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => {
// RFC 1918 private network ranges — allow any port on these hosts.
// We parse the hostname out of the origin URL and check against each range.
let hostname: string;
let protocol: string;
let parsed: URL;
try {
const parsed = new URL(origin);
hostname = parsed.hostname;
protocol = parsed.protocol;
parsed = new URL(origin);
} catch {
// Malformed origin — reject
return false;
}
// Only allow HTTP(S) origins — reject ftp://, file://, etc.
if (protocol !== 'http:' && protocol !== 'https:') return false;
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
return isRfc1918PrivateIpv4(hostname);
// The matcher is rebuilt per call, so changing the env var takes effect
// without a restart. The write guard in middleware.ts snapshots it instead.
if (createPublicOriginMatcher(process.env[PUBLIC_ORIGIN_ENV])?.matches(parsed)) return true;
return isRfc1918PrivateIpv4(parsed.hostname);
};
type GraphStreamRecord =
@ -703,6 +715,11 @@ export function validateAnalyzeToken(
}
export const createServer = async (port: number, host: string = '127.0.0.1') => {
// Refuse a public-origin config before anything is opened or bound: `serve`
// has no authentication yet, so the setting that makes a public bind usable
// must not be usable either. Throws — `serve` reports it and exits non-zero.
assertServeAuthForPublicOrigin();
// Surface a cleartext Azure DevOps PAT config at boot (operators rarely
// read per-request logs). Warn-only — http:// self-hosted stays supported.
warnIfInsecureAzureConfig();
@ -710,26 +727,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const app = express();
app.disable('x-powered-by');
// Trust X-Forwarded-* headers only when the connection comes from the
// local loopback or RFC1918 private/link-local addresses — exactly the
// origins the CORS allowlist accepts. Without this, every request behind
// any reverse proxy / Docker bridge counts as the same `req.ip` and a
// single user can trip the per-IP rate limiter for everyone.
//
// SCOPE: this setting is process-wide. Every middleware and route in this
// Express app sees req.ip resolved from X-Forwarded-For when the upstream
// hop is in the trusted set above — not just the rate-limited routes.
// Future IP-based middleware (audit logging, IP-bound authz) inherits this
// behavior.
//
// CLOUD-DEPLOY CAVEAT: a public cloud LB (AWS ALB, Cloudflare, Fly.io
// edge, CGNAT 100.64/10) is NOT in the trusted set. In those topologies
// req.ip will collapse to the LB hop IP for every request and the per-IP
// rate limiter degrades to per-server. Add an explicit env-var override
// and document the cloud-deploy story before binding to a non-loopback
// host in those topologies (tracked as a follow-up; not blocking for the
// local-bound default).
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
// Which upstream hops may set X-Forwarded-*. Process-wide: every route's
// req.ip, and so the per-IP rate limiter, resolves through this.
app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV]));
// resolveTrustProxy validates the value in isolation; only here do we know
// what we bound, and so whether the default is about to collapse the per-IP
// rate limit to one global limit behind a load balancer.
warnIfRateLimitKeysCollapse(host);
// Chromium Private Network Access (required since Chrome 130+). Must run before
// cors: the cors middleware ends OPTIONS preflight responses, so this header
@ -753,21 +757,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
);
app.use(express.json({ limit: '10mb' }));
// Same-host origin guard for write routes. Only allows loopback and the
// server's own bound host — scoped to prevent CSRF from other LAN devices.
const requireLocalhostOrigin = createLocalhostOriginGuard(host);
// A wildcard bind (`0.0.0.0`/`::`) has no single host identity for the
// same-host check, so browser write routes accept only loopback origins.
// Warn the operator so a remote-access deployment isn't silently write-blocked.
if (host && normalizeBoundHost(host) === undefined) {
logger.warn(
{ host },
`[gitnexus serve] Bound to a wildcard address (${host}); browser write routes ` +
`accept only loopback origins (localhost/127.0.0.1/[::1]). To allow writes from a ` +
`specific LAN address, bind --host <that-address> instead of a wildcard.`,
);
}
// Origin guard for write routes: loopback, the server's own bound host, and
// any configured public origin — prevents CSRF from other devices.
const requireTrustedOrigin = createWriteOriginGuard(host, port);
logOriginPolicy(host);
// No explicit OPTIONS route is registered. The Chromium Private Network
// Access header is set by the global middleware above (pre-cors), and
@ -988,7 +981,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Rate-limited (CodeQL js/missing-rate-limiting): destructive operation
// doing fs.rm of clone + storage dirs. Default 60 rpm/IP is generous for
// delete; tighten if abuse is observed.
app.delete('/api/repo', createRouteLimiter(), requireLocalhostOrigin, async (req, res) => {
app.delete('/api/repo', createRouteLimiter(), requireTrustedOrigin, async (req, res) => {
try {
const repoName = requestedRepo(req);
if (!repoName) {
@ -1496,7 +1489,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
app.post(
'/api/analyze',
createRouteLimiter({ limit: 10 }),
requireLocalhostOrigin,
requireTrustedOrigin,
async (req, res) => {
try {
const {
@ -1535,9 +1528,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// (both collapse `..` identically) and only false-rejected trailing
// slashes, so it is dropped. Analyzing a local path the operator names
// is the tool's intended capability (same as the CLI); the dangerous
// part was cross-origin reach, which is closed by requireLocalhostOrigin
// on this route (scoped to the server's own bound host — other LAN
// devices are NOT trusted). We only require an absolute path here and
// part was cross-origin reach, which is closed by requireTrustedOrigin
// on this route (scoped to loopback, the server's own bound host, and a
// configured GITNEXUS_PUBLIC_ORIGIN — other LAN devices are NOT
// trusted). We only require an absolute path here and
// let the analyze worker surface a clear error if it does not exist.
// (We do NOT realpath/stat the path in-route: that would be a
// user-controlled filesystem read — CodeQL js/path-injection — for no
@ -1627,7 +1621,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
app.post(
'/api/analyze/upload',
createRouteLimiter({ limit: 5 }),
requireLocalhostOrigin,
requireTrustedOrigin,
createAnalyzeUploadHandler({
createJob: (params) => jobManager.createJob(params),
launch: (job, targetPath, opts) => launchAnalysisWorker(job, targetPath, opts),
@ -1659,7 +1653,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
mountSSEProgress(app, '/api/analyze/:jobId/progress', jobManager);
// DELETE /api/analyze/:jobId — cancel a running analysis job
app.delete('/api/analyze/:jobId', requireLocalhostOrigin, (req, res) => {
app.delete('/api/analyze/:jobId', requireTrustedOrigin, (req, res) => {
const jobId = req.params.jobId as string;
const job = jobManager.getJob(jobId);
if (!job) {
@ -1682,7 +1676,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
app.post(
'/api/embed',
createRouteLimiter({ limit: 20 }),
requireLocalhostOrigin,
requireTrustedOrigin,
async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
@ -1981,7 +1975,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
mountSSEProgress(app, '/api/embed/:jobId/progress', embedJobManager);
// DELETE /api/embed/:jobId — cancel embedding job
app.delete('/api/embed/:jobId', requireLocalhostOrigin, (req, res) => {
app.delete('/api/embed/:jobId', requireTrustedOrigin, (req, res) => {
const jobId = req.params.jobId as string;
const job = embedJobManager.getJob(jobId);
if (!job) {

View file

@ -1,13 +1,36 @@
/**
* Shared Express route guards (alongside createRouteLimiter in validation.ts).
* Shared Express route guards (alongside createRouteLimiter in validation.ts),
* plus the `serve` configuration surface they read: GITNEXUS_PUBLIC_ORIGIN and
* GITNEXUS_TRUST_PROXY.
*/
import type { Request, Response } from 'express';
import proxyaddr from 'proxy-addr';
import { logger } from '../core/logger.js';
/** Port a browser omits from `Origin`, so both sides compare on one value. */
function effectivePort(url: URL): string {
if (url.port) return url.port;
return url.protocol === 'https:' ? '443' : '80';
}
/**
* Canonicalize a bound-host string into the form a browser `Origin` hostname
* takes after WHATWG URL parsing, so the same-host comparison in
* {@link createLocalhostOriginGuard} can use a plain `===`.
* The three loopback spellings a browser can send, and that
* {@link normalizeBoundHost} can return.
*
* Takes a hostname already through WHATWG URL parsing, so IPv6 arrives
* bracketed unlike `isLoopbackHost` in `mcp/http-transport.ts`, which compares
* a raw `--host` value and so matches bare `::1`.
*/
function isLoopbackHostname(hostname: string | undefined): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
}
/**
* Canonicalize a configured host `--host`, or the one inside
* {@link PUBLIC_ORIGIN_ENV} into the form a browser `Origin` hostname takes
* after WHATWG URL parsing, so the comparisons in
* {@link createWriteOriginGuard} can use a plain `===`.
*
* Returns `undefined` when the host carries no single comparable identity:
* - empty / not provided
@ -40,20 +63,128 @@ export function normalizeBoundHost(boundHost?: string): string | undefined {
}
/**
* Restrict a route to same-host browser origins. Allows:
* Browser origin a hosted deployment is reached through. A wildcard bind makes
* {@link normalizeBoundHost} undefined, so writes would stay loopback-only.
*/
export const PUBLIC_ORIGIN_ENV = 'GITNEXUS_PUBLIC_ORIGIN';
/**
* Matches a parsed browser `Origin` against {@link PUBLIC_ORIGIN_ENV}. Carries
* the hostname the env value resolved to, so startup can log what it parsed.
*/
export interface PublicOriginMatcher {
readonly hostname: string;
matches(origin: URL): boolean;
}
/**
* Build a matcher for {@link PUBLIC_ORIGIN_ENV}. Compares hostname and scheme
* always a value with no scheme is read as `https`, never as either and
* port only when the configured value carried an explicit one.
*
* `undefined` when unset or not a single reachable host, mirroring
* {@link normalizeBoundHost}: an invalid origin must never widen the
* allow-list, and must not read as a configured one either.
*/
export function createPublicOriginMatcher(rawOrigin?: string): PublicOriginMatcher | undefined {
const trimmed = rawOrigin?.trim();
if (!trimmed) return undefined;
const scheme = /^(https?):\/\//i.exec(trimmed)?.[1].toLowerCase();
// An `Origin` never carries a path, but a pasted URL often ends in a slash.
const raw = (scheme ? trimmed.slice(scheme.length + 3) : trimmed).replace(/\/$/, '');
const authority = splitAuthority(raw);
if (!authority) return undefined;
const hostname = normalizeBoundHost(authority.host);
if (!hostname) return undefined;
// A bare host stays permissive on the port, since platform service-discovery
// fields resolve to a hostname with neither — but it defaults to https rather
// than to any scheme, because those platforms always terminate TLS, and
// accepting either would make `app.example.com` an http downgrade path into
// both the CORS read allowlist and the write guard. Plain http needs the
// explicit `http://` form.
const expectedProtocol = `${scheme ?? 'https'}:`;
let expectedPort: string | undefined;
if (authority.port) {
let configured: URL;
try {
// Round-trip through `new URL` so the configured port normalizes the same
// way a request Origin's does — an elided default, a leading zero — and so
// an out-of-range port throws here rather than yielding a dead matcher.
configured = new URL(`${expectedProtocol}//${hostname}:${authority.port}`);
} catch {
return undefined;
}
// Port 0 survives that round-trip (so does `:00`) but no browser ever sends
// it, so keeping it would build exactly the dead-but-configured matcher this
// function exists to reject. `:0080` is unaffected — it normalizes to the
// elided default, not to 0.
if (configured.port === '0') return undefined;
expectedPort = effectivePort(configured);
}
return {
hostname,
matches: (origin: URL): boolean =>
origin.hostname === hostname &&
origin.protocol === expectedProtocol &&
(expectedPort === undefined || effectivePort(origin) === expectedPort),
};
}
/**
* Split `host[:port]` into parts `new URL` can reassemble, or `undefined` when
* the value is not a single reachable host.
*/
function splitAuthority(authority: string): { host: string; port?: string } | undefined {
const bracketed = /^(\[[0-9A-Fa-f:.]+\])(?::(\d{1,5}))?$/.exec(authority);
if (bracketed) return { host: bracketed[1], port: bracketed[2] };
// More than one colon means a bare IPv6 literal, which carries no port.
if ((authority.match(/:/g)?.length ?? 0) > 1) {
return /^[0-9A-Fa-f:.]+$/.test(authority) ? { host: `[${authority}]` } : undefined;
}
const parts = /^([^:]+)(?::(\d{1,5}))?$/.exec(authority);
if (!parts) return undefined;
const host = parts[1];
// `new URL` is far laxer than DNS: it takes `a.com,b.com` verbatim and reads
// `8080` as the integer IP 0.0.31.144, either of which yields a matcher that
// can never match a real Origin while still reading as configured. A trailing
// dot is the same failure — it is a legal FQDN that survives parsing as
// `example.com.`, but a browser sends `example.com`, so the matcher is dead.
if (/[\s,;*/?#@\\]/.test(host) || /^\d+$/.test(host) || host.endsWith('.')) return undefined;
return { host, port: parts[2] };
}
/**
* Restrict a route to the browser origins this server trusts. Allows:
* - loopback (`localhost`, `127.0.0.1`, `[::1]`)
* - the server's own bound host (when non-loopback, e.g. a LAN IP)
* - the configured public origin ({@link PUBLIC_ORIGIN_ENV}), if any
*
* Non-browser requests (no Origin header, e.g. curl / the CLI) pass through.
* This closes cross-origin reach to write routes without affecting read routes.
*
* Loopback stays port-agnostic the dev UI and the server run on different
* ports but the bound host is matched on its port too, when one is given.
*
* @param boundHost - The hostname/IP the server is listening on (from
* `createServer`'s `host` parameter). When `undefined`, `'localhost'`, or a
* wildcard (`0.0.0.0`/`::`), only loopback origins are admitted.
* @param boundPort - The port the server actually listens on. Omit it and
* match the bound host on any port when that is not known, as it is not
* for an ephemeral `--port 0` bind.
*/
export function createLocalhostOriginGuard(boundHost?: string) {
export function createWriteOriginGuard(boundHost?: string, boundPort?: number) {
const normalizedBoundHost = normalizeBoundHost(boundHost);
return function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void {
const normalizedBoundPort = boundPort === undefined ? undefined : String(boundPort);
// Snapshotted at construction like normalizedBoundHost, so a later env
// mutation cannot widen a running server's write surface.
const publicOrigin = createPublicOriginMatcher(process.env[PUBLIC_ORIGIN_ENV]);
return function requireTrustedOrigin(req: Request, res: Response, next: () => void): void {
const origin = req.headers.origin;
if (origin === undefined) {
next();
@ -61,20 +192,14 @@ export function createLocalhostOriginGuard(boundHost?: string) {
}
try {
const parsed = new URL(origin);
const hostname = parsed.hostname;
const protocol = parsed.protocol;
const { hostname, protocol } = parsed;
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error('Unsupported origin protocol');
}
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
next();
return;
}
// Allow origin matching the server's own bound host (same-host check).
// `normalizedBoundHost` is canonicalized to the WHATWG form `hostname`
// already carries; it is `undefined` for wildcard/no binds (loopback-only).
// This covers the case where the operator runs `gitnexus serve --host <LAN-IP>`.
if (normalizedBoundHost && hostname === normalizedBoundHost) {
const matchesBoundHost =
hostname === normalizedBoundHost &&
(normalizedBoundPort === undefined || effectivePort(parsed) === normalizedBoundPort);
if (isLoopbackHostname(hostname) || matchesBoundHost || publicOrigin?.matches(parsed)) {
next();
return;
}
@ -82,14 +207,192 @@ export function createLocalhostOriginGuard(boundHost?: string) {
/* malformed origin → reject */
}
res.status(403).json({
error: 'This endpoint is restricted to same-host origins',
error: 'This endpoint is restricted to trusted browser origins',
code: 'origin_not_allowed',
});
};
}
/**
* Default guard that only allows loopback origins. For use in tests or when
* the bound host is not available.
* Whether `serve` has any request authentication configured.
*
* Nothing can configure it yet: `serve` has no authentication of any kind, and
* {@link createWriteOriginGuard} passes every request that carries no `Origin`
* header, so `curl` reaches `POST /api/analyze` and `DELETE /api/repo`
* unauthenticated. That has been safe only because `serve` bound loopback.
*
* So this returns `false` unconditionally, and it is a placeholder on purpose:
* the `serve` auth change replaces this body, and {@link assertServeAuthForPublicOrigin}
* and its tests then hold without being rewritten.
*/
export const requireLocalhostOrigin = createLocalhostOriginGuard();
export function isServeAuthConfigured(): boolean {
return false;
}
/**
* Refuse to start when {@link PUBLIC_ORIGIN_ENV} is set and no `serve`
* authentication is configured.
*
* {@link PUBLIC_ORIGIN_ENV} is the setting that makes a public bind usable it
* is what admits a non-loopback browser origin to the write routes. Until
* {@link isServeAuthConfigured} can return `true`, setting it opens the door
* with nothing behind it, so the door does not open at all. There is
* deliberately no override flag: an escape hatch is the thing an operator sets
* once and forgets, which is exactly the state this guards against.
*
* @throws when {@link PUBLIC_ORIGIN_ENV} is set without authentication. `serve`
* surfaces it as `serve.startFailed` and exits non-zero.
*/
export function assertServeAuthForPublicOrigin(): void {
const raw = process.env[PUBLIC_ORIGIN_ENV]?.trim();
if (!raw || isServeAuthConfigured()) return;
throw new Error(
`${PUBLIC_ORIGIN_ENV} is set (${raw}), but 'gitnexus serve' has no authentication yet. ` +
`It would admit browser writes from that origin, and requests without an Origin header ` +
`(curl, any script) already reach POST /api/analyze and DELETE /api/repo unauthenticated — ` +
`so a reachable deployment would let anyone index and delete repositories. Unset ` +
`${PUBLIC_ORIGIN_ENV} and bind loopback (the default), or reach the server through a proxy ` +
`that authenticates for it.`,
);
}
/**
* Report at startup what {@link createWriteOriginGuard} will admit, so an
* operator can see it without reproducing a 403. A wildcard bind always warns
* gating that on {@link PUBLIC_ORIGIN_ENV} being constructible would diagnose a
* misconfigured value worse than an absent one.
*/
export function logOriginPolicy(boundHost?: string): void {
const raw = process.env[PUBLIC_ORIGIN_ENV]?.trim();
const publicOrigin = createPublicOriginMatcher(raw);
if (publicOrigin) {
logger.info(
{ [PUBLIC_ORIGIN_ENV]: raw, hostname: publicOrigin.hostname },
`[gitnexus serve] Browser write routes also accept origins on ${publicOrigin.hostname}.`,
);
} else if (raw) {
logger.warn(
{ [PUBLIC_ORIGIN_ENV]: raw },
`[gitnexus serve] Ignoring ${PUBLIC_ORIGIN_ENV}=${raw} — not a single reachable origin, ` +
`so it admits nothing. Set it to one host, optionally with a scheme and a port.`,
);
}
if (!boundHost || normalizeBoundHost(boundHost) !== undefined) return;
const admitted = publicOrigin
? `accept loopback origins (localhost/127.0.0.1/[::1]) and ${publicOrigin.hostname} via ` +
`${PUBLIC_ORIGIN_ENV}.`
: `accept only loopback origins (localhost/127.0.0.1/[::1]). To admit writes from a specific ` +
`LAN address, bind --host <that-address> instead of a wildcard; to admit them from a ` +
`public origin, set ${PUBLIC_ORIGIN_ENV} to it.`;
logger.warn(
{ host: boundHost },
`[gitnexus serve] Bound to a wildcard address (${boundHost}); browser write routes ${admitted}`,
);
}
/** Loopback + RFC1918 + link-local: the hops a self-hosted install sees. */
export const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
/** Overrides {@link DEFAULT_TRUST_PROXY}; a public cloud LB needs it set. */
export const TRUST_PROXY_ENV = 'GITNEXUS_TRUST_PROXY';
/**
* Sanity ceiling on a hop count, well past any real proxy chain it exists to
* catch a digit string long enough to overflow to `Infinity`, not to make any
* value under it safe. The correct hop count is the exact number of proxies you
* control; each extra hop hands the caller one more entry of the chain.
*/
export const MAX_TRUST_PROXY_HOPS = 16;
/**
* Resolve {@link TRUST_PROXY_ENV} to a value Express accepts for `trust proxy`:
* `false` (`false`/`no`/`off`, and a `0` hop count), a hop count in
* `1..{@link MAX_TRUST_PROXY_HOPS}`, or a proxy list Express can compile.
* Anything else warns and returns {@link DEFAULT_TRUST_PROXY}. Express compiles
* this value inside `app.set`, so an unvalidated bad one takes `serve` down at
* startup; a number it accepts without any range check at all.
*
* `true` is rejected, not accepted-with-a-warning. It makes `req.ip` the
* client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a
* fresh rate-limit key per request and the limiter is the only thing in front
* of `/api/analyze` and `/api/embed`, both of which spawn workers. It is also
* not a working configuration: express-rate-limit's own `validations.trustProxy`
* throws `ERR_ERL_PERMISSIVE_TRUST_PROXY` on it. Any real chain has a knowable
* length, so a hop count or a proxy list covers every legitimate case.
*/
export function resolveTrustProxy(raw?: string): string | number | boolean {
const value = raw?.trim();
if (!value) return DEFAULT_TRUST_PROXY;
if (/^(true|yes|on)$/i.test(value)) {
return rejectTrustProxy(
value,
`it trusts every hop, so req.ip is read from the client-controlled leftmost ` +
`X-Forwarded-For entry and a spoofed chain earns a fresh rate-limit key per request; ` +
`express-rate-limit rejects it too. Set the number of proxies you control instead`,
);
}
if (/^(false|no|off)$/i.test(value)) return false;
if (/^\d+$/.test(value)) {
const hops = Number(value);
// Express tests a hop count as `i < hops`, so 0 trusts nothing, exactly as
// `false` does. Rejecting it would fall back to a default that trusts more
// than was asked for.
if (hops === 0) return false;
// The same test means a digit string long enough to overflow to Infinity
// trusts the whole chain rather than failing loudly.
if (Number.isInteger(hops) && hops <= MAX_TRUST_PROXY_HOPS) return hops;
return rejectTrustProxy(value, `expected a hop count of 0..${MAX_TRUST_PROXY_HOPS}`);
}
try {
// Mirror Express 5's compileTrust (`express/lib/utils.js`): it splits on `,`
// and trims before handing the list to proxy-addr, which rejects unknown
// subnet names itself. proxy-addr does not split, so pass the array form.
proxyaddr.compile(value.split(',').map((entry) => entry.trim()));
} catch (err) {
return rejectTrustProxy(value, err instanceof Error ? err.message : String(err));
}
return value;
}
/**
* Warn when the rate limiter is about to key every request to the same address.
*
* {@link resolveTrustProxy} cannot detect this it sees the env value and not
* what the server bound. A non-loopback bind is the shape of a deployment behind
* a load balancer, and {@link DEFAULT_TRUST_PROXY} matches only loopback and the
* private ranges, so a cloud LB outside them is never trusted: `req.ip` is the
* LB on every request and the per-IP limit silently becomes one global limit.
*
* Silent when {@link TRUST_PROXY_ENV} is set including to a value that then
* fails validation, which {@link resolveTrustProxy} has already warned about.
*
* @param boundHost - `createServer`'s `host`. A wildcard bind warns too: it
* accepts traffic on every interface, a load balancer included.
*/
export function warnIfRateLimitKeysCollapse(boundHost?: string): void {
if (process.env[TRUST_PROXY_ENV]?.trim()) return;
if (!boundHost) return;
// normalizeBoundHost returns undefined for a wildcard or unparseable host,
// neither of which is loopback — so both warn.
if (isLoopbackHostname(normalizeBoundHost(boundHost))) return;
logger.warn(
{ host: boundHost, trustProxy: DEFAULT_TRUST_PROXY },
`[gitnexus serve] Bound to ${boundHost} with ${TRUST_PROXY_ENV} unset, so 'trust proxy' is ` +
`'${DEFAULT_TRUST_PROXY}'. A load balancer outside those ranges is not trusted, so req.ip ` +
`is the balancer on every request and the per-IP rate limit becomes one shared limit across ` +
`all callers. Set ${TRUST_PROXY_ENV} to the number of proxies you control.`,
);
}
function rejectTrustProxy(value: string, reason: string): string {
logger.warn(
{ [TRUST_PROXY_ENV]: value },
`[gitnexus serve] Ignoring ${TRUST_PROXY_ENV}=${value} (${reason}); falling back to ` +
`'${DEFAULT_TRUST_PROXY}'.`,
);
return DEFAULT_TRUST_PROXY;
}

View file

@ -3,7 +3,7 @@
*
* The unit tests (api-analyze-token.test.ts) cover validateAnalyzeToken in
* isolation; this proves the REAL production route actually wires it in
* express.json body parsing, the requireLocalhostOrigin guard, the route
* express.json body parsing, the requireTrustedOrigin guard, the route
* handler invoking the validator, and the 400 status/error shape on the wire.
* Closes the gap the PR #2223 tri-review noted: "the route validation is
* otherwise only reachable by booting the server."

View file

@ -86,7 +86,21 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
}
});
it('serve boots and GET /api/health returns ok', async () => {
/**
* Spawn the built CLI's `serve` on an ephemeral port with a throwaway
* GITNEXUS_HOME, registering both for afterEach cleanup. The streams are
* captured separately so a failure message can attribute output to the right
* one, and returned as getters because they fill in after this resolves.
*/
const spawnServe = async (
host: string,
extraEnv: Record<string, string> = {},
): Promise<{
child: ChildProcessWithoutNullStreams;
port: number;
stdout: () => string;
stderr: () => string;
}> => {
if (!fs.existsSync(DIST_CLI)) {
throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`);
}
@ -94,34 +108,40 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
const port = await allocateFreePort();
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-serve-home-'));
proc = spawn(
const child = spawn(
process.execPath,
[DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'],
[DIST_CLI, 'serve', '--port', String(port), '--host', host],
{
cwd: REPO_ROOT,
env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' },
env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '', ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
proc = child;
let stdout = '';
let stderr = '';
proc.stdout.on('data', (buf) => {
child.stdout.on('data', (buf) => {
stdout += buf.toString();
});
proc.stderr.on('data', (buf) => {
child.stderr.on('data', (buf) => {
stderr += buf.toString();
});
return { child, port, stdout: () => stdout, stderr: () => stderr };
};
it('serve boots and GET /api/health returns ok', async () => {
const { child, port, stdout, stderr } = await spawnServe('127.0.0.1');
const startedAt = Date.now();
let status = 0;
let body = '';
while (Date.now() - startedAt < STARTUP_BUDGET_MS) {
if (proc.exitCode !== null) {
if (child.exitCode !== null) {
throw new Error(
`serve exited ${proc.exitCode} before ready.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
`serve exited ${child.exitCode} before ready.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`,
);
}
try {
@ -138,4 +158,40 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
expect(status).toBe(200);
expect(body).toContain('"status":"ok"');
}, 60_000);
// GITNEXUS_PUBLIC_ORIGIN is what makes a public bind usable, and `serve` has
// no authentication — so it must not boot at all. Asserted end-to-end rather
// than at the unit level, because what matters is the exit code the operator
// (or a platform health check) sees, not that a function threw.
it('serve refuses to start when GITNEXUS_PUBLIC_ORIGIN is set', async () => {
const { child, port, stdout, stderr } = await spawnServe('0.0.0.0', {
GITNEXUS_PUBLIC_ORIGIN: 'https://gitnexus.example.com',
});
const exitCode = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`serve did not exit within budget.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`,
),
),
STARTUP_BUDGET_MS,
);
child.on('exit', (code) => {
clearTimeout(timer);
resolve(code);
});
child.on('error', reject);
});
expect(exitCode).toBe(1);
// The CLI prints the failure through cliError, so it lands on stderr; assert
// against both streams rather than pinning which one.
const output = stdout() + stderr();
expect(output).toContain('GITNEXUS_PUBLIC_ORIGIN');
expect(output).toContain('no authentication');
// And it never got as far as listening.
await expect(probeHealth(port)).rejects.toThrow();
}, 60_000);
});

View file

@ -1,10 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import { Readable } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import { createAnalyzeUploadHandler } from '../../src/server/analyze-upload.js';
import { requireLocalhostOrigin, createLocalhostOriginGuard } from '../../src/server/middleware.js';
import { PUBLIC_ORIGIN_ENV, createWriteOriginGuard } from '../../src/server/middleware.js';
// The guard admits GITNEXUS_PUBLIC_ORIGIN as well as loopback, and the
// rejection cases below assume none is configured. Clear the developer's
// ambient value for the file rather than inheriting it.
const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV];
beforeAll(() => {
delete process.env[PUBLIC_ORIGIN_ENV];
});
afterAll(() => {
if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin;
});
const BOUNDARY = '----gitnexusuploadtest';
@ -258,7 +269,7 @@ describe('createAnalyzeUploadHandler', () => {
});
});
describe('requireLocalhostOrigin', () => {
describe('createWriteOriginGuard (no bound host)', () => {
function call(origin: string | undefined): { passed: boolean; status: number } {
let passed = false;
let status = 0;
@ -269,7 +280,7 @@ describe('requireLocalhostOrigin', () => {
return { json: () => {} };
},
} as never;
requireLocalhostOrigin(req, res, () => {
createWriteOriginGuard()(req, res, () => {
passed = true;
});
return { passed, status };
@ -288,7 +299,7 @@ describe('requireLocalhostOrigin', () => {
expect(r.status).toBe(403);
});
it('rejects RFC1918 origins when no boundHost is set (default guard)', () => {
it('rejects RFC1918 origins when no boundHost is set', () => {
expect(call('http://10.0.0.1:4173').passed).toBe(false);
expect(call('http://172.16.1.21:4173').passed).toBe(false);
expect(call('http://192.168.1.100:4173').passed).toBe(false);
@ -301,12 +312,12 @@ describe('requireLocalhostOrigin', () => {
});
});
describe('createLocalhostOriginGuard (bound host)', () => {
describe('createWriteOriginGuard (bound host)', () => {
function callWith(
boundHost: string,
origin: string | undefined,
): { passed: boolean; status: number; body?: { error?: string; code?: string } } {
const guard = createLocalhostOriginGuard(boundHost);
const guard = createWriteOriginGuard(boundHost);
let passed = false;
let status = 0;
let body: { error?: string; code?: string } | undefined;

View file

@ -13,8 +13,20 @@
* - https://gitnexus.vercel.app → allowed
* - Everything else rejected
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest';
import { isAllowedOrigin } from '../../src/server/api.js';
import { createPublicOriginMatcher } from '../../src/server/middleware.js';
// isAllowedOrigin consults GITNEXUS_PUBLIC_ORIGIN, so every expectation below
// assumes it is unset unless the test sets it. Clear the developer's ambient
// value for the file rather than inheriting it.
const ambientPublicOrigin = process.env.GITNEXUS_PUBLIC_ORIGIN;
beforeAll(() => {
delete process.env.GITNEXUS_PUBLIC_ORIGIN;
});
afterAll(() => {
if (ambientPublicOrigin !== undefined) process.env.GITNEXUS_PUBLIC_ORIGIN = ambientPublicOrigin;
});
// ─── No origin (non-browser / curl) ──────────────────────────────────
@ -181,3 +193,147 @@ describe('isAllowedOrigin: rejected origins', () => {
expect(isAllowedOrigin('http://172.16.5.1:3000')).toBe(true);
});
});
describe('isAllowedOrigin: GITNEXUS_PUBLIC_ORIGIN', () => {
// Back to the file's cleared baseline, not to the ambient value afterAll
// restores — the tests above assume it stays unset.
afterEach(() => {
delete process.env.GITNEXUS_PUBLIC_ORIGIN;
});
it('allows the configured origin as a full URL or a bare host', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
});
it('enforces the port when the configured value carries one', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com:8443';
expect(isAllowedOrigin('https://app.example.com:8443')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:9999')).toBe(false);
// The browser elides the default port, so a bare origin is port 443 here.
expect(isAllowedOrigin('https://app.example.com')).toBe(false);
});
it('accepts any port when the configured value carries none', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:8443')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:9999')).toBe(true);
});
it('matches a configured default port against an origin that elides it', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com:443';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
expect(isAllowedOrigin('https://app.example.com:8443')).toBe(false);
});
it('handles a bracketed IPv6 literal with a port, with and without a scheme', () => {
// No scheme means https, so the http probes below are scheme mismatches.
process.env.GITNEXUS_PUBLIC_ORIGIN = '[2001:db8::1]:8080';
expect(isAllowedOrigin('https://[2001:db8::1]:8080')).toBe(true);
expect(isAllowedOrigin('https://[2001:db8::1]:9090')).toBe(false);
expect(isAllowedOrigin('http://[2001:db8::1]:8080')).toBe(false);
process.env.GITNEXUS_PUBLIC_ORIGIN = 'http://[2001:db8::1]:8080';
expect(isAllowedOrigin('http://[2001:db8::1]:8080')).toBe(true);
expect(isAllowedOrigin('https://[2001:db8::1]:8080')).toBe(false);
});
it('accepts any port on a bare IPv6 literal, which carries none', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = '[2001:db8::1]';
expect(isAllowedOrigin('https://[2001:db8::1]:4173')).toBe(true);
// Non-canonical forms compress to the same hostname a browser Origin has.
process.env.GITNEXUS_PUBLIC_ORIGIN = '2001:db8:0:0:0:0:0:1';
expect(isAllowedOrigin('https://[2001:db8::1]:4173')).toBe(true);
});
it('does not widen to other hosts', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://evil.example.com')).toBe(false);
expect(isAllowedOrigin('https://app.example.com.evil.com')).toBe(false);
});
it('enforces the scheme when the configured value carries one', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com';
expect(isAllowedOrigin('http://app.example.com')).toBe(false);
});
// A bare host is the platform service-discovery form, and those terminate
// TLS — so it means https, not either scheme. Accepting either would make it
// an http downgrade path into the read allowlist and the write guard alike.
it('reads a bare host as https, not as either scheme', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('https://app.example.com')).toBe(true);
expect(isAllowedOrigin('http://app.example.com')).toBe(false);
});
it('accepts http on a bare host only when http:// is spelled out', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'http://app.example.com';
expect(isAllowedOrigin('http://app.example.com')).toBe(true);
expect(isAllowedOrigin('https://app.example.com')).toBe(false);
});
it('still rejects non-http protocols on the configured host', () => {
process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com';
expect(isAllowedOrigin('ftp://app.example.com')).toBe(false);
});
it('is inert when unset', () => {
delete process.env.GITNEXUS_PUBLIC_ORIGIN;
expect(isAllowedOrigin('https://app.example.com')).toBe(false);
});
});
// A value that yields a matcher no real Origin can satisfy is worse than no
// value at all: it reads as configured, so the wildcard-bind warning in
// createServer reports a working origin where there is none.
describe('createPublicOriginMatcher: values that are not one reachable host', () => {
it.each([
['', 'empty'],
[' ', 'whitespace only'],
['*', 'a wildcard'],
['a.com,b.com', 'a comma-separated list'],
['a.com;b.com', 'a semicolon-separated list'],
['8080', 'a bare port number — new URL reads it as the integer IP 0.0.31.144'],
['under score', 'an embedded space'],
['a.com:99999', 'a port out of range'],
['a.com:0', 'port 0, which parses but which no browser ever sends'],
['https://a.com:00', 'port 00, which normalizes to 0 rather than to a default'],
['ftp://a.com', 'a non-http scheme'],
['https://a.com/ui', 'a path, which an Origin never has'],
['0.0.0.0', 'a wildcard bind, which has no host identity'],
['a.com.', 'a trailing dot — a legal FQDN, but not what a browser sends'],
['https://a.com.:8443', 'a trailing dot with a scheme and a port'],
])('returns undefined for %j (%s)', (raw) => {
expect(createPublicOriginMatcher(raw)).toBeUndefined();
});
it('returns undefined when the env var is unset', () => {
expect(createPublicOriginMatcher(undefined)).toBeUndefined();
});
});
describe('createPublicOriginMatcher: values that resolve to one host', () => {
it('tolerates the trailing slash a pasted URL carries', () => {
const matcher = createPublicOriginMatcher('https://app.example.com/');
expect(matcher?.hostname).toBe('app.example.com');
expect(matcher?.matches(new URL('https://app.example.com'))).toBe(true);
});
// Guards the port-0 rejection above from over-reaching: a leading zero on a
// real port normalizes to that port, not to 0.
it('reads a leading-zero port as the port it normalizes to', () => {
const matcher = createPublicOriginMatcher('http://a.com:0080');
expect(matcher?.matches(new URL('http://a.com'))).toBe(true);
expect(matcher?.matches(new URL('http://a.com:80'))).toBe(true);
expect(matcher?.matches(new URL('http://a.com:8080'))).toBe(false);
});
it('reports the hostname it resolved, for the startup log line', () => {
expect(createPublicOriginMatcher('https://App.Example.com:8443')?.hostname).toBe(
'app.example.com',
);
expect(createPublicOriginMatcher('2001:db8:0:0:0:0:0:1')?.hostname).toBe('[2001:db8::1]');
});
});

View file

@ -0,0 +1,294 @@
/**
* Unit Tests: the write-route origin guard's port awareness, and what it
* reports at startup.
*
* cors.test.ts covers the read side (isAllowedOrigin) of the same matcher.
* This file covers the write side createWriteOriginGuard where a
* mismatch is a 403 rather than a missing CORS header, plus the port-aware
* bound-host comparison and logOriginPolicy's startup diagnostics.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
PUBLIC_ORIGIN_ENV,
assertServeAuthForPublicOrigin,
createWriteOriginGuard,
isServeAuthConfigured,
logOriginPolicy,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
// The bound-host cases never set the var, so clear the developer's ambient
// value for the file and return each test to that cleared baseline.
const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV];
beforeAll(() => {
delete process.env[PUBLIC_ORIGIN_ENV];
});
afterAll(() => {
if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin;
});
afterEach(() => {
delete process.env[PUBLIC_ORIGIN_ENV];
});
function setPublicOrigin(value: string | undefined): void {
if (value === undefined) delete process.env[PUBLIC_ORIGIN_ENV];
else process.env[PUBLIC_ORIGIN_ENV] = value;
}
interface GuardResult {
passed: boolean;
status: number;
body?: { error?: string; code?: string };
}
// The guard snapshots the env var at construction, so callers set it first.
function callGuard(
boundHost: string | undefined,
boundPort: number | undefined,
origin: string,
): GuardResult {
const guard = createWriteOriginGuard(boundHost, boundPort);
let passed = false;
let status = 0;
let body: { error?: string; code?: string } | undefined;
const req = { headers: { origin } } as never;
const res = {
status: (c: number) => {
status = c;
return {
json: (b: { error?: string; code?: string }) => {
body = b;
},
};
},
} as never;
guard(req, res, () => {
passed = true;
});
return { passed, status, body };
}
describe('createWriteOriginGuard — bound host is matched on its port', () => {
it('admits the bound host on the bound port', () => {
expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10:8443').passed).toBe(true);
});
it('rejects the bound host on a different port with origin_not_allowed', () => {
const res = callGuard('192.168.1.10', 8443, 'http://192.168.1.10:9999');
expect(res.passed).toBe(false);
expect(res.status).toBe(403);
expect(res.body?.code).toBe('origin_not_allowed');
});
it('rejects the bound host on the default port when it is not the bound one', () => {
// The pre-existing bug: `hostname === normalizedBoundHost` matched here.
expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10').passed).toBe(false);
expect(callGuard('192.168.1.10', 8443, 'https://192.168.1.10').passed).toBe(false);
});
it('treats an elided default port as that port', () => {
expect(callGuard('192.168.1.10', 80, 'http://192.168.1.10').passed).toBe(true);
expect(callGuard('192.168.1.10', 443, 'https://192.168.1.10').passed).toBe(true);
expect(callGuard('192.168.1.10', 80, 'https://192.168.1.10').passed).toBe(false);
});
it('matches any port when no bound port is given', () => {
expect(callGuard('192.168.1.10', undefined, 'http://192.168.1.10:9999').passed).toBe(true);
});
it('keeps loopback port-agnostic — the dev UI runs on its own port', () => {
expect(callGuard('192.168.1.10', 8443, 'http://localhost:5173').passed).toBe(true);
expect(callGuard('192.168.1.10', 8443, 'http://127.0.0.1:4173').passed).toBe(true);
expect(callGuard('192.168.1.10', 8443, 'http://[::1]:4173').passed).toBe(true);
});
});
describe('createWriteOriginGuard — public origin is matched on its port', () => {
it('admits an exact scheme/host/port match on a wildcard bind', () => {
setPublicOrigin('https://app.example.com:8443');
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:8443').passed).toBe(true);
});
it('rejects a port mismatch with a 403 and origin_not_allowed', () => {
setPublicOrigin('https://app.example.com:8443');
const res = callGuard('0.0.0.0', 3000, 'https://app.example.com:9999');
expect(res.passed).toBe(false);
expect(res.status).toBe(403);
expect(res.body?.code).toBe('origin_not_allowed');
});
it('rejects a scheme mismatch when the configured value carries a scheme', () => {
setPublicOrigin('https://app.example.com');
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false);
});
// A bare host is permissive on the port but NOT on the scheme: it defaults to
// https, so `app.example.com` is not an http downgrade path into the write
// routes. Plain http needs the explicit form.
it('admits any port for a bare configured host, but only over https', () => {
setPublicOrigin('app.example.com');
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:9999').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false);
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(false);
});
it('admits http for a bare host only when http:// is spelled out', () => {
setPublicOrigin('http://app.example.com');
expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false);
});
it('admits a bracketed IPv6 literal, on any port when configured without one', () => {
setPublicOrigin('[2001:db8::1]');
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(true);
setPublicOrigin('https://[2001:db8::1]:8080');
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:8080').passed).toBe(true);
expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(false);
});
// A trailing dot is a legal FQDN that survives `new URL` as `example.com.`,
// but a browser sends `example.com` — so it built a matcher nothing could
// satisfy while logOriginPolicy reported it as working.
it('admits nothing for a trailing-dot hostname', () => {
setPublicOrigin('app.example.com.');
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false);
expect(callGuard('0.0.0.0', 3000, 'https://app.example.com.').passed).toBe(false);
});
it('admits nothing extra when the configured value is not one reachable host', () => {
setPublicOrigin('a.com,b.com');
expect(callGuard('0.0.0.0', 3000, 'https://a.com').passed).toBe(false);
expect(callGuard('0.0.0.0', 3000, 'https://b.com').passed).toBe(false);
});
it('leaves the Origin-less passthrough alone — the CLI sends no Origin', () => {
setPublicOrigin('app.example.com');
const guard = createWriteOriginGuard('0.0.0.0', 3000);
let passed = false;
guard({ headers: {} } as never, {} as never, () => {
passed = true;
});
expect(passed).toBe(true);
});
});
describe('logOriginPolicy', () => {
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
});
const infos = () => cap.records().filter((r) => r.level === 30);
const warns = () => cap.records().filter((r) => r.level === 40);
it('says nothing on a specific bind with no public origin', () => {
setPublicOrigin(undefined);
logOriginPolicy('192.168.1.10');
expect(cap.records()).toEqual([]);
});
it('names the resolved hostname at info when a public origin is configured', () => {
setPublicOrigin('https://App.Example.com:8443');
logOriginPolicy('192.168.1.10');
expect(infos()).toHaveLength(1);
expect(String(infos()[0].msg)).toContain('app.example.com');
expect(infos()[0].hostname).toBe('app.example.com');
expect(warns()).toEqual([]);
});
// The four-way matrix: wildcard bind × public origin absent/valid/invalid.
it('warns on a wildcard bind with no public origin, pointing at both remedies', () => {
setPublicOrigin(undefined);
logOriginPolicy('0.0.0.0');
expect(warns()).toHaveLength(1);
expect(String(warns()[0].msg)).toContain('wildcard address (0.0.0.0)');
expect(String(warns()[0].msg)).toContain('--host');
expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV);
});
it('still warns on a wildcard bind with a valid public origin, and names it', () => {
setPublicOrigin('https://app.example.com');
logOriginPolicy('::');
expect(infos()).toHaveLength(1);
expect(warns()).toHaveLength(1);
expect(String(warns()[0].msg)).toContain('app.example.com');
});
it('diagnoses an unusable public origin rather than reporting one', () => {
setPublicOrigin('a.com,b.com');
logOriginPolicy('0.0.0.0');
expect(infos()).toEqual([]);
// One for the unusable value, one for the wildcard bind it fails to rescue.
expect(warns()).toHaveLength(2);
expect(String(warns()[0].msg)).toContain('a.com,b.com');
expect(String(warns()[1].msg)).toContain('--host');
});
it('diagnoses an unusable public origin on a specific bind too', () => {
setPublicOrigin('*');
logOriginPolicy('192.168.1.10');
expect(warns()).toHaveLength(1);
expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV);
});
it('does not report a trailing-dot hostname as a working origin', () => {
setPublicOrigin('app.example.com.');
logOriginPolicy('0.0.0.0');
expect(infos()).toEqual([]);
expect(String(warns()[0].msg)).toContain('app.example.com.');
});
});
/**
* `serve` has no authentication, and the guard above passes every request with
* no Origin header so GITNEXUS_PUBLIC_ORIGIN, the setting that makes a public
* bind usable, must not be usable until the lock exists.
*/
describe('assertServeAuthForPublicOrigin', () => {
it('is a no-op when no public origin is configured', () => {
setPublicOrigin(undefined);
expect(() => assertServeAuthForPublicOrigin()).not.toThrow();
});
it.each([' ', ''])('treats a blank value (%j) as unset', (raw) => {
setPublicOrigin(raw);
expect(() => assertServeAuthForPublicOrigin()).not.toThrow();
});
it('throws when a public origin is configured and no auth is', () => {
setPublicOrigin('https://app.example.com');
expect(() => assertServeAuthForPublicOrigin()).toThrow(/has no authentication yet/);
});
it('names the variable and the value in the failure, and points at both remedies', () => {
setPublicOrigin('https://app.example.com');
let message = '';
try {
assertServeAuthForPublicOrigin();
} catch (err) {
message = err instanceof Error ? err.message : String(err);
}
expect(message).toContain(PUBLIC_ORIGIN_ENV);
expect(message).toContain('https://app.example.com');
expect(message).toContain('DELETE /api/repo');
expect(message).toContain('loopback');
});
// Even a value the matcher would reject throws: the operator's intent to serve
// a public origin is the risk, and an unusable value is not a safer one.
it.each(['a.com,b.com', '*', '8080'])('throws on an unusable value too: %s', (raw) => {
setPublicOrigin(raw);
expect(() => assertServeAuthForPublicOrigin()).toThrow();
});
// The auth change flips this predicate; the gate above is then satisfiable
// without rewriting it. Pinned so the flip cannot happen unnoticed.
it('reports no auth configured, since serve has none', () => {
expect(isServeAuthConfigured()).toBe(false);
});
});

View file

@ -0,0 +1,191 @@
/**
* Unit Tests: GITNEXUS_TRUST_PROXY resolution
*
* Express accepts a boolean, a hop count, or a comma-separated proxy list for
* `trust proxy`, and compiles the value inside `app.set` so an unvalidated
* env value takes the server down at startup, or (for a number it cannot
* range-check) silently trusts every hop. resolveTrustProxy validates first.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_TRUST_PROXY,
MAX_TRUST_PROXY_HOPS,
TRUST_PROXY_ENV,
resolveTrustProxy,
warnIfRateLimitKeysCollapse,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
// `logger` is a Proxy with only a `get` trap, so vi.spyOn cannot replace
// `warn` on it; the module's own capture helper redirects the destination.
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
});
const warnings = (): string[] =>
cap
.records()
.filter((r) => r.level === 40)
.map((r) => String(r.msg));
describe('resolveTrustProxy — accepted', () => {
it('falls back to the loopback-scoped default when unset or blank', () => {
expect(resolveTrustProxy(undefined)).toBe(DEFAULT_TRUST_PROXY);
expect(resolveTrustProxy('')).toBe(DEFAULT_TRUST_PROXY);
expect(resolveTrustProxy(' ')).toBe(DEFAULT_TRUST_PROXY);
expect(warnings()).toEqual([]);
});
it('accepts the default it falls back to, so the fallback can never throw', () => {
expect(() => resolveTrustProxy(DEFAULT_TRUST_PROXY)).not.toThrow();
expect(resolveTrustProxy(DEFAULT_TRUST_PROXY)).toBe(DEFAULT_TRUST_PROXY);
expect(warnings()).toEqual([]);
});
const hopCounts = Array.from({ length: MAX_TRUST_PROXY_HOPS }, (_, i) => i + 1);
it.each(hopCounts)('accepts hop count %i', (hops) => {
expect(() => resolveTrustProxy(String(hops))).not.toThrow();
expect(resolveTrustProxy(String(hops))).toBe(hops);
expect(warnings()).toEqual([]);
});
it('trims surrounding whitespace off a hop count', () => {
expect(resolveTrustProxy(' 2 ')).toBe(2);
});
// Express tests a hop count as `i < hops`, so 0 and false are the same
// setting. Rejecting 0 would fall back to a default that trusts more.
it('normalizes a hop count of 0 to false rather than rejecting it', () => {
expect(resolveTrustProxy('0')).toBe(false);
expect(warnings()).toEqual([]);
});
it.each([
['false', false],
['FALSE', false],
['no', false],
['NO', false],
['off', false],
['OFF', false],
] as const)('accepts %s as a boolean without warning', (raw, expected) => {
expect(resolveTrustProxy(raw)).toBe(expected);
expect(warnings()).toEqual([]);
});
it.each(['loopback', 'linklocal', 'uniquelocal', '10.0.0.0/8, 127.0.0.1'])(
'accepts the proxy list %s verbatim',
(raw) => {
expect(() => resolveTrustProxy(raw)).not.toThrow();
expect(resolveTrustProxy(raw)).toBe(raw);
expect(warnings()).toEqual([]);
},
);
});
// `true` trusts every hop, which makes req.ip the client-controlled leftmost
// X-Forwarded-For entry — a fresh rate-limit key per spoofed request, in front
// of the two routes that spawn workers. express-rate-limit's own
// validations.trustProxy throws ERR_ERL_PERMISSIVE_TRUST_PROXY on it, so it was
// never a working configuration either. Rejected, not warned.
describe('resolveTrustProxy — rejects a trust-everything value', () => {
it.each(['true', 'TRUE', 'yes', 'YES', 'on', 'ON'])('falls back to the default on %s', (raw) => {
expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(raw);
expect(warned[0]).toContain('X-Forwarded-For');
});
it('never returns true, so express-rate-limit cannot reject the value we set', () => {
for (const raw of ['true', 'yes', 'on', 'TRUE', '1', '16', 'loopback', '0', 'false']) {
expect(resolveTrustProxy(raw)).not.toBe(true);
}
});
});
describe('resolveTrustProxy — rejected', () => {
it.each([
['garbage', 'an unknown subnet name'],
['*', 'a wildcard'],
['9'.repeat(400), 'a hop count that overflows to Infinity'],
[String(MAX_TRUST_PROXY_HOPS + 1), 'a hop count above the range'],
['-1', 'a negative hop count'],
['1.5', 'a fractional hop count'],
['a.com;b.com', 'a semicolon-separated list'],
])('falls back to the default on %#: %s', (raw) => {
expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(raw);
});
});
// resolveTrustProxy sees only the env value; whether the default is about to
// collapse the per-IP rate limit to one global limit depends on what we bound.
describe('warnIfRateLimitKeysCollapse', () => {
const original = process.env[TRUST_PROXY_ENV];
beforeEach(() => {
delete process.env[TRUST_PROXY_ENV];
});
afterEach(() => {
if (original === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = original;
});
it.each(['localhost', '127.0.0.1', '::1', '[::1]'])('stays silent on a %s bind', (host) => {
warnIfRateLimitKeysCollapse(host);
expect(warnings()).toEqual([]);
});
it.each([undefined, ''])('stays silent when no host is given (%o)', (host) => {
warnIfRateLimitKeysCollapse(host);
expect(warnings()).toEqual([]);
});
it.each([
['0.0.0.0', 'a wildcard bind accepts LB traffic too'],
['::', 'the IPv6 wildcard likewise'],
['192.168.1.10', 'a LAN bind'],
['203.0.113.7', 'a public bind'],
])('warns on %s (%s)', (host) => {
warnIfRateLimitKeysCollapse(host);
const warned = warnings();
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(TRUST_PROXY_ENV);
expect(warned[0]).toContain(host);
expect(warned[0]).toContain('one shared limit');
});
it.each(['1', 'loopback', 'garbage'])(
'stays silent when %s is configured, valid or not',
(raw) => {
// An invalid value is resolveTrustProxy's warning to make, not a second one
// here — the operator has already been told about that value.
process.env[TRUST_PROXY_ENV] = raw;
warnIfRateLimitKeysCollapse('0.0.0.0');
expect(warnings()).toEqual([]);
},
);
it('treats a whitespace-only value as unset', () => {
process.env[TRUST_PROXY_ENV] = ' ';
warnIfRateLimitKeysCollapse('0.0.0.0');
expect(warnings()).toHaveLength(1);
});
});
describe('resolveTrustProxy — contract', () => {
it('names the env var it reads', () => {
expect(TRUST_PROXY_ENV).toBe('GITNEXUS_TRUST_PROXY');
});
it('defaults to loopback plus the private ranges', () => {
expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal');
});
});

View file

@ -23,6 +23,12 @@ import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { createRouteLimiter } from '../../src/server/validation.js';
import {
DEFAULT_TRUST_PROXY,
TRUST_PROXY_ENV,
resolveTrustProxy,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
let tmpFile: string;
@ -246,13 +252,13 @@ describe('production routes — rate-limit middleware wiring', () => {
it('POST /api/analyze is wired with createRouteLimiter', () => {
// Tolerate Prettier wrapping the registration across lines (it does once
// the route carries extra middleware like requireLocalhostOrigin).
// the route carries extra middleware like requireTrustedOrigin).
expect(apiSource).toMatch(/app\.post\(\s*'\/api\/analyze',\s*createRouteLimiter\(/);
});
it('POST /api/embed is wired with createRouteLimiter', () => {
// Tolerate Prettier wrapping the registration across lines (it does once
// the route carries extra middleware like requireLocalhostOrigin).
// the route carries extra middleware like requireTrustedOrigin).
expect(apiSource).toMatch(/app\.post\(\s*'\/api\/embed',\s*createRouteLimiter\(/);
});
@ -269,9 +275,13 @@ describe('production routes — rate-limit middleware wiring', () => {
expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/);
});
it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => {
// Source-level because createServer listens and cannot be built here. Kept
// deliberately loose: the effective-value describe below covers resolution,
// so all this has to pin down is that createServer routes the env var
// through resolveTrustProxy rather than setting a literal.
it('createServer reads trust proxy from GITNEXUS_TRUST_PROXY', () => {
expect(apiSource).toMatch(
/app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/,
/app\.set\(\s*'trust proxy'\s*,\s*resolveTrustProxy\([^)]*TRUST_PROXY_ENV/,
);
});
@ -318,3 +328,107 @@ describe('validation.ts — IPv6 key normalisation (#1360)', () => {
expect(validationSource).toMatch(/ipKeyGenerator\(ip\)/);
});
});
// The effective `trust proxy` Express ends up with, rather than the text of the
// line that sets it. `createServer` listens and installs signal handlers, so it
// cannot be built here; this mirrors its one `app.set` expression instead.
describe('trust proxy — effective value from GITNEXUS_TRUST_PROXY', () => {
const saved = process.env[TRUST_PROXY_ENV];
// The rejection cases below warn; capture keeps them out of the suite output.
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
if (saved === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = saved;
});
const effectiveTrustProxy = (value: string | undefined): unknown => {
if (value === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = value;
const app = express();
app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV]));
return app.get('trust proxy');
};
it('defaults to the loopback-scoped list when the env var is unset', () => {
expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal');
expect(effectiveTrustProxy(undefined)).toBe('loopback, linklocal, uniquelocal');
});
it('carries a configured hop count through to Express', () => {
expect(effectiveTrustProxy('3')).toBe(3);
});
it('carries a configured proxy list through to Express', () => {
expect(effectiveTrustProxy('10.0.0.0/8, 127.0.0.1')).toBe('10.0.0.0/8, 127.0.0.1');
});
// Express 5 compiles `trust proxy` inside `app.set`, so an unvalidated bad
// value would throw during createServer instead of resolving to a default.
it('never hands Express a value it rejects', () => {
expect(() => effectiveTrustProxy('garbage')).not.toThrow();
expect(effectiveTrustProxy('garbage')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('9'.repeat(400))).toBe(DEFAULT_TRUST_PROXY);
});
// The behaviour the describe block below demonstrates is why: `true` cannot
// reach Express through the env var at all.
it('never hands Express `true`', () => {
expect(effectiveTrustProxy('true')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('yes')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('on')).toBe(DEFAULT_TRUST_PROXY);
});
});
// Why resolveTrustProxy rejects `true`: Express then reads the leftmost
// X-Forwarded-For entry, which the client controls, so rotating it hands the
// limiter a fresh key per request — unbounded, in front of the two routes that
// spawn workers. A hop count reads from the right instead and is immune. Both
// apps below see the same requests; only the setting differs. `true` is set
// directly here, since the env var can no longer produce it.
describe('trust proxy — a rotating X-Forwarded-For defeats `true` but not a hop count', () => {
const buildProxiedApp = (trustProxy: boolean | number): Express => {
const app = express();
app.set('trust proxy', trustProxy);
app.get('/test/file', createRouteLimiter({ windowMs: 2000, limit: 2 }), async (_req, res) => {
const content = await fs.readFile(tmpFile, 'utf-8');
res.json({ content });
});
return app;
};
// Leftmost entry rotates per request; the rightmost (the hop the loopback
// proxy claims to have received from) stays fixed.
const statusesUnderRotatingForwardedFor = async (app: Express): Promise<number[]> => {
const { server, baseUrl } = await startServer(app);
try {
const statuses: number[] = [];
for (let i = 1; i <= 4; i++) {
const res = await fetch(`${baseUrl}/test/file`, {
headers: { 'X-Forwarded-For': `203.0.113.${i}, 198.51.100.7` },
});
statuses.push(res.status);
}
return statuses;
} finally {
await stopServer(server);
}
};
it('collapses to a single limiter key with a hop count of 1', async () => {
expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(1))).toEqual([
200, 200, 429, 429,
]);
});
it('gets a fresh limiter key per request with `true`', async () => {
expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(true))).toEqual([
200, 200, 200, 200,
]);
});
});