diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index c55519b3d..be30b61d5 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -5,7 +5,7 @@ * Also hosts the MCP server over StreamableHTTP for remote AI tool access. * * Security: binds to 127.0.0.1 by default (use --host to override). - * CORS is restricted to localhost and the deployed site. + * CORS is restricted to localhost, private/LAN networks, and the deployed site. */ import express from 'express'; @@ -23,6 +23,74 @@ import { hybridSearch } from '../core/search/hybrid-search.js'; import { LocalBackend } from '../mcp/local/local-backend.js'; import { mountMCPEndpoints } from './mcp-http.js'; +/** + * Determine whether an HTTP Origin header value is allowed by CORS policy. + * + * Permitted origins: + * - No origin (non-browser requests such as curl or server-to-server calls) + * - http://localhost: — local development + * - http://127.0.0.1: — loopback alias + * - RFC 1918 private/LAN networks (any port): + * 10.0.0.0/8 → 10.x.x.x + * 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 + * + * @param origin - The value of the HTTP `Origin` request header, or `undefined` + * when the header is absent (non-browser request). + * @returns `true` if the origin is allowed, `false` otherwise. + */ +export const isAllowedOrigin = (origin: string | undefined): boolean => { + if (origin === undefined) { + // Non-browser requests (curl, server-to-server) have no Origin header + return true; + } + + if ( + origin.startsWith('http://localhost:') + || origin === 'http://localhost' + || origin.startsWith('http://127.0.0.1:') + || origin === 'http://127.0.0.1' + || origin.startsWith('http://[::1]:') + || origin === 'http://[::1]' + || origin === 'https://gitnexus.vercel.app' + ) { + return true; + } + + // 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; + try { + const parsed = new URL(origin); + hostname = parsed.hostname; + protocol = parsed.protocol; + } catch { + // Malformed origin — reject + return false; + } + + // Only allow HTTP(S) origins — reject ftp://, file://, etc. + if (protocol !== 'http:' && protocol !== 'https:') return false; + + const octets = hostname.split('.').map(Number); + if (octets.length !== 4 || octets.some(o => !Number.isInteger(o) || o < 0 || o > 255)) { + return false; + } + + const [a, b] = octets; + + // 10.0.0.0/8 + if (a === 10) return true; + // 172.16.0.0/12 → 172.16.x.x – 172.31.x.x + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + + return false; +}; + const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => { const nodes: GraphNode[] = []; for (const table of NODE_TABLES) { @@ -107,16 +175,11 @@ const requestedRepo = (req: express.Request): string | undefined => { export const createServer = async (port: number, host: string = '127.0.0.1') => { const app = express(); - // CORS: only allow localhost origins and the deployed site. + // CORS: allow localhost, private/LAN networks, and the deployed site. // Non-browser requests (curl, server-to-server) have no origin and are allowed. app.use(cors({ origin: (origin, callback) => { - if ( - !origin - || origin.startsWith('http://localhost:') - || origin.startsWith('http://127.0.0.1:') - || origin === 'https://gitnexus.vercel.app' - ) { + if (isAllowedOrigin(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); diff --git a/gitnexus/test/unit/cors.test.ts b/gitnexus/test/unit/cors.test.ts new file mode 100644 index 000000000..d8bccf7c7 --- /dev/null +++ b/gitnexus/test/unit/cors.test.ts @@ -0,0 +1,183 @@ +/** + * Unit Tests: CORS origin allowlist + * + * Tests isAllowedOrigin() from server/api.ts, which controls which HTTP + * Origins are permitted by the Express CORS middleware. + * + * Policy: + * - No origin (non-browser) → allowed + * - http://localhost: → allowed + * - http://127.0.0.1: → allowed + * - RFC 1918 private network ranges → allowed + * 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + * - https://gitnexus.vercel.app → allowed + * - Everything else → rejected + */ +import { describe, it, expect } from 'vitest'; +import { isAllowedOrigin } from '../../src/server/api.js'; + +// ─── No origin (non-browser / curl) ────────────────────────────────── + +describe('isAllowedOrigin: no origin', () => { + it('allows undefined origin (curl, server-to-server)', () => { + expect(isAllowedOrigin(undefined)).toBe(true); + }); +}); + +// ─── Localhost variants ─────────────────────────────────────────────── + +describe('isAllowedOrigin: localhost', () => { + it('allows http://localhost:3000', () => { + expect(isAllowedOrigin('http://localhost:3000')).toBe(true); + }); + + it('allows http://localhost:5173 (Vite default)', () => { + expect(isAllowedOrigin('http://localhost:5173')).toBe(true); + }); + + it('allows http://localhost:8080', () => { + expect(isAllowedOrigin('http://localhost:8080')).toBe(true); + }); + + it('allows http://127.0.0.1:3000', () => { + expect(isAllowedOrigin('http://127.0.0.1:3000')).toBe(true); + }); + + it('allows http://127.0.0.1:5173', () => { + expect(isAllowedOrigin('http://127.0.0.1:5173')).toBe(true); + }); +}); + +// ─── Deployed site ──────────────────────────────────────────────────── + +describe('isAllowedOrigin: vercel.app', () => { + it('allows https://gitnexus.vercel.app', () => { + expect(isAllowedOrigin('https://gitnexus.vercel.app')).toBe(true); + }); + + it('rejects other vercel.app subdomains', () => { + expect(isAllowedOrigin('https://evil.vercel.app')).toBe(false); + }); +}); + +// ─── RFC 1918: 10.0.0.0/8 ──────────────────────────────────────────── + +describe('isAllowedOrigin: 10.x.x.x (RFC 1918, /8)', () => { + it('allows http://10.0.0.1:3000', () => { + expect(isAllowedOrigin('http://10.0.0.1:3000')).toBe(true); + }); + + it('allows http://10.1.2.3:5173', () => { + expect(isAllowedOrigin('http://10.1.2.3:5173')).toBe(true); + }); + + it('allows http://10.255.255.255:8080', () => { + expect(isAllowedOrigin('http://10.255.255.255:8080')).toBe(true); + }); +}); + +// ─── RFC 1918: 172.16.0.0/12 ───────────────────────────────────────── + +describe('isAllowedOrigin: 172.16-31.x.x (RFC 1918, /12)', () => { + it('allows http://172.16.0.1:3000 (lower bound)', () => { + expect(isAllowedOrigin('http://172.16.0.1:3000')).toBe(true); + }); + + it('allows http://172.20.1.2:3000 (middle of range)', () => { + expect(isAllowedOrigin('http://172.20.1.2:3000')).toBe(true); + }); + + it('allows http://172.31.255.255:3000 (upper bound)', () => { + expect(isAllowedOrigin('http://172.31.255.255:3000')).toBe(true); + }); + + it('rejects http://172.15.0.1:3000 (below range)', () => { + expect(isAllowedOrigin('http://172.15.0.1:3000')).toBe(false); + }); + + it('rejects http://172.32.0.1:3000 (above range)', () => { + expect(isAllowedOrigin('http://172.32.0.1:3000')).toBe(false); + }); +}); + +// ─── RFC 1918: 192.168.0.0/16 ──────────────────────────────────────── + +describe('isAllowedOrigin: 192.168.x.x (RFC 1918, /16)', () => { + it('allows http://192.168.0.1:3000 (typical home router gateway)', () => { + expect(isAllowedOrigin('http://192.168.0.1:3000')).toBe(true); + }); + + it('allows http://192.168.1.100:5173', () => { + expect(isAllowedOrigin('http://192.168.1.100:5173')).toBe(true); + }); + + it('allows http://192.168.255.254:8080', () => { + expect(isAllowedOrigin('http://192.168.255.254:8080')).toBe(true); + }); + + it('rejects http://192.167.1.1:3000 (adjacent, not private)', () => { + expect(isAllowedOrigin('http://192.167.1.1:3000')).toBe(false); + }); + + it('rejects http://192.169.1.1:3000 (adjacent, not private)', () => { + expect(isAllowedOrigin('http://192.169.1.1:3000')).toBe(false); + }); +}); + +// ─── Public / untrusted origins ─────────────────────────────────────── + +describe('isAllowedOrigin: rejected origins', () => { + it('rejects https://evil.com', () => { + expect(isAllowedOrigin('https://evil.com')).toBe(false); + }); + + it('rejects https://example.com', () => { + expect(isAllowedOrigin('https://example.com')).toBe(false); + }); + + it('rejects http://8.8.8.8:3000 (Google DNS, public IP)', () => { + expect(isAllowedOrigin('http://8.8.8.8:3000')).toBe(false); + }); + + it('rejects https://gitnexus.example.com (not the official domain)', () => { + expect(isAllowedOrigin('https://gitnexus.example.com')).toBe(false); + }); + + it('rejects malformed origin string', () => { + expect(isAllowedOrigin('not-a-url')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isAllowedOrigin('')).toBe(false); + }); + + // Localhost without explicit port (port 80 implied) + it('allows http://localhost without port', () => { + expect(isAllowedOrigin('http://localhost')).toBe(true); + }); + + it('allows http://127.0.0.1 without port', () => { + expect(isAllowedOrigin('http://127.0.0.1')).toBe(true); + }); + + // IPv6 loopback + it('allows IPv6 loopback http://[::1]:3000', () => { + expect(isAllowedOrigin('http://[::1]:3000')).toBe(true); + }); + + it('allows IPv6 loopback http://[::1] without port', () => { + expect(isAllowedOrigin('http://[::1]')).toBe(true); + }); + + // Protocol validation + it('rejects non-HTTP(S) origins from private IPs', () => { + expect(isAllowedOrigin('ftp://10.0.0.1')).toBe(false); + expect(isAllowedOrigin('ftp://192.168.1.1')).toBe(false); + }); + + it('allows HTTP and HTTPS from private IPs', () => { + expect(isAllowedOrigin('http://192.168.1.100')).toBe(true); + expect(isAllowedOrigin('https://10.0.0.50')).toBe(true); + expect(isAllowedOrigin('http://172.16.5.1:3000')).toBe(true); + }); +}); \ No newline at end of file