fix(server): allow private/LAN network origins in CORS (#390)

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
This commit is contained in:
Shunsuke Hayashi 2026-03-22 13:16:14 +09:00
parent 2c866d7ad4
commit 7b5dd0c04c
2 changed files with 254 additions and 8 deletions

View file

@ -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:<port> — local development
* - http://127.0.0.1:<port> — 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'));

View file

@ -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:<port> → allowed
* - http://127.0.0.1:<port> → 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);
});
});