mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
Merge pull request #424 from ShunsukeHayashi/fix/lan-cors-390
This commit is contained in:
commit
4c8be50cb1
2 changed files with 254 additions and 8 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
183
gitnexus/test/unit/cors.test.ts
Normal file
183
gitnexus/test/unit/cors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue