GitNexus/gitnexus/test/unit/middleware-public-origin.test.ts

294 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
});