Merge pull request #424 from ShunsukeHayashi/fix/lan-cors-390

This commit is contained in:
Gergő Magyar 2026-03-22 09:39:42 +00:00 committed by GitHub
commit 4c8be50cb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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);
});
});