From d07a69c3b1b86c1af1c4f6b3d2cd491d2e037ecc Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sun, 22 Mar 2026 13:16:14 +0900 Subject: [PATCH] fix(server): allow private/LAN network origins in CORS (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow RFC 1918 private network ranges in the CORS origin allowlist so users running GitNexus on their home or office LAN can access the web UI from another device on the same network. Permitted private ranges: 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) The origin check is extracted into an exported isAllowedOrigin() helper so it can be unit-tested in isolation. A new test file covers: - No origin (curl / server-to-server) - localhost and 127.0.0.1 variants - All three RFC 1918 ranges including boundary values - The deployed gitnexus.vercel.app site - Public / untrusted origins that must be rejected The server bind address (127.0.0.1 by default) is unchanged; this PR only affects which cross-origin browser requests are accepted. Closes #390 --- gitnexus/src/server/api.ts | 79 ++++++++++++-- gitnexus/test/unit/cors.test.ts | 183 ++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 gitnexus/test/unit/cors.test.ts 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