diff --git a/gitnexus-web/src/components/Header.tsx b/gitnexus-web/src/components/Header.tsx index 3fae0c48f..3dc83301c 100644 --- a/gitnexus-web/src/components/Header.tsx +++ b/gitnexus-web/src/components/Header.tsx @@ -27,6 +27,7 @@ import { EmbeddingStatus } from './EmbeddingStatus'; import { RepoAnalyzer } from './RepoAnalyzer'; import { LanguageSwitcher } from './LanguageSwitcher'; import { translateProgressMessage } from '../i18n/progress'; +import { formatBackendError } from '../i18n/error-messages'; // Color mapping for node types in search results const NODE_TYPE_COLORS: Record = { @@ -58,7 +59,7 @@ export const Header = ({ onAnalyzeComplete, onReposChanged, }: HeaderProps) => { - const { t } = useTranslation(['common', 'header']); + const { t } = useTranslation(['common', 'header', 'errors']); const { projectName, graph, @@ -72,6 +73,7 @@ export const Header = ({ const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false); const [showAnalyzer, setShowAnalyzer] = useState(false); const [reanalyzing, setReanalyzing] = useState(null); // repo name being re-analyzed + const [deleteError, setDeleteError] = useState(null); // surfaced when a delete is rejected (e.g. origin-blocked 403) const [reanalyzeProgress, setReanalyzeProgress] = useState(null); const reanalyzeSseRef = useRef(null); const repoDropdownRef = useRef(null); @@ -305,6 +307,7 @@ export const Header = ({ setReanalyzeProgress(null); reanalyzeSseRef.current = null; } + setDeleteError(null); try { await deleteRepo(repo.name); const updated = await fetchRepos(); @@ -317,7 +320,11 @@ export const Header = ({ window.location.reload(); } } catch (err) { + // Surface the failure instead of silently no-opping — + // e.g. an origin-blocked 403 when driving a local + // backend from the hosted UI. console.error('Failed to delete repo:', err); + setDeleteError(formatBackendError(err, t)); } }} className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400" @@ -330,6 +337,13 @@ export const Header = ({ )} + {/* Surfaced delete failure (e.g. origin-blocked 403) */} + {deleteError && ( +
+ {deleteError} +
+ )} + {/* Re-analyze progress bar */} {reanalyzing && reanalyzeProgress && (
diff --git a/gitnexus-web/src/i18n/error-messages.ts b/gitnexus-web/src/i18n/error-messages.ts index 620350b93..1af2169aa 100644 --- a/gitnexus-web/src/i18n/error-messages.ts +++ b/gitnexus-web/src/i18n/error-messages.ts @@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string { return t('errors:backend.rateLimited', { seconds, defaultValue: fallback }); case 'not_found': return t('errors:backend.notFound', { defaultValue: fallback }); + case 'origin_blocked': + return t('errors:backend.originBlocked', { defaultValue: fallback }); case 'client': return t('errors:backend.client', { message: error.message, defaultValue: fallback }); case 'server': diff --git a/gitnexus-web/src/locales/en/errors.json b/gitnexus-web/src/locales/en/errors.json index e7bdcde33..c3f727078 100644 --- a/gitnexus-web/src/locales/en/errors.json +++ b/gitnexus-web/src/locales/en/errors.json @@ -13,6 +13,7 @@ "timeout": "The server took too long to respond. Try again in a moment.", "rateLimited": "Too many requests. Try again in {{seconds}}s.", "notFound": "The requested repository or resource was not found.", + "originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.", "client": "Request failed: {{message}}", "server": "Server error: {{message}}" } diff --git a/gitnexus-web/src/locales/zh-CN/errors.json b/gitnexus-web/src/locales/zh-CN/errors.json index 47dcb4f8d..d567d4ff9 100644 --- a/gitnexus-web/src/locales/zh-CN/errors.json +++ b/gitnexus-web/src/locales/zh-CN/errors.json @@ -13,6 +13,7 @@ "timeout": "服务器响应超时,请稍后重试。", "rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。", "notFound": "未找到请求的仓库或资源。", + "originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。", "client": "请求失败:{{message}}", "server": "服务器错误:{{message}}" } diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 286a13187..b12cf45a9 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -79,7 +79,11 @@ export class BackendError extends Error { | 'client' | 'not_found' | 'timeout' - | 'rate_limited', + | 'rate_limited' + // The write-route same-host Origin guard rejected this request (HTTP 403 + // with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client` + // 403 so the UI can show actionable "open the local UI" guidance. + | 'origin_blocked', /** * Milliseconds until the caller should retry. Populated for rate-limited * responses (HTTP 429) from the server's `Retry-After` header. `undefined` @@ -361,6 +365,7 @@ const assertOk = async (response: Response): Promise => { if (response.ok) return; let message = response.statusText; + let bodyCode: string | undefined; try { const body = await response.json(); if (body && typeof body.error === 'string') { @@ -368,6 +373,9 @@ const assertOk = async (response: Response): Promise => { } else if (body && typeof body.message === 'string') { message = body.message; } + if (body && typeof body.code === 'string') { + bodyCode = body.code; + } } catch { // Response body was not JSON } @@ -377,9 +385,13 @@ const assertOk = async (response: Response): Promise => { ? 'not_found' : response.status === 429 ? 'rate_limited' - : response.status >= 400 && response.status < 500 - ? 'client' - : 'server'; + : // The write-route Origin guard returns 403 with this discriminator; + // surface it as a distinct code so the UI can give actionable guidance. + bodyCode === 'origin_not_allowed' + ? 'origin_blocked' + : response.status >= 400 && response.status < 500 + ? 'client' + : 'server'; // Retry-After is the standard HTTP signal for when the client may try again. // express-rate-limit emits it on 429 with seconds (integer) or HTTP-date. diff --git a/gitnexus-web/test/unit/backend-client-retry.test.ts b/gitnexus-web/test/unit/backend-client-retry.test.ts index ea0fcf3a7..e25dc9a73 100644 --- a/gitnexus-web/test/unit/backend-client-retry.test.ts +++ b/gitnexus-web/test/unit/backend-client-retry.test.ts @@ -13,7 +13,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getBreaker } from 'gitnexus-shared'; import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers'; -import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client'; +import { + deleteRepo, + fetchRepos, + setBackendUrl, + startAnalyze, +} from '../../src/services/backend-client'; const BASE = 'http://localhost:4747'; @@ -89,6 +94,40 @@ describe('backend-client retry budget (method-aware)', () => { expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0); }); + it('maps an origin-blocked 403 to BackendError code "origin_blocked"', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + error: 'This endpoint is restricted to same-host origins', + code: 'origin_not_allowed', + }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(deleteRepo('my-repo')).rejects.toMatchObject({ + status: 403, + code: 'origin_blocked', + }); + // 403 is a terminal client error — never retried. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('maps a generic 403 (no recognized code) to BackendError code "client" (back-compat)', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ error: 'forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(deleteRepo('my-repo')).rejects.toMatchObject({ status: 403, code: 'client' }); + }); + it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => { // Reject directly with a TimeoutError DOMException, mimicking what // `fetch` produces when its `AbortSignal.timeout()`-wired signal diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 67ae63009..05836d350 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -35,10 +35,11 @@ import { JobManager } from './analyze-job.js'; import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js'; import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js'; import { createAnalyzeUploadHandler } from './analyze-upload.js'; -import { requireLocalhostOrigin } from './middleware.js'; +import { createLocalhostOriginGuard, normalizeBoundHost } from './middleware.js'; import { createLaunchAnalysisWorker } from './analyze-launch.js'; import { UPLOAD_ROOT } from './upload-paths.js'; import { sweepStaleUploads } from './upload-sweep.js'; +import { isRfc1918PrivateIpv4 } from './private-ip.js'; import { logger, flushLoggerSync } from '../core/logger.js'; const _require = createRequire(import.meta.url); @@ -95,21 +96,7 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => { // 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; + return isRfc1918PrivateIpv4(hostname); }; type GraphStreamRecord = @@ -733,6 +720,22 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); app.use(express.json({ limit: '10mb' })); + // Same-host origin guard for write routes. Only allows loopback and the + // server's own bound host — scoped to prevent CSRF from other LAN devices. + const requireLocalhostOrigin = createLocalhostOriginGuard(host); + + // A wildcard bind (`0.0.0.0`/`::`) has no single host identity for the + // same-host check, so browser write routes accept only loopback origins. + // Warn the operator so a remote-access deployment isn't silently write-blocked. + if (host && normalizeBoundHost(host) === undefined) { + logger.warn( + { host }, + `[gitnexus serve] Bound to a wildcard address (${host}); browser write routes ` + + `accept only loopback origins (localhost/127.0.0.1/[::1]). To allow writes from a ` + + `specific LAN address, bind --host instead of a wildcard.`, + ); + } + // No explicit OPTIONS route is registered. The Chromium Private Network // Access header is set by the global middleware above (pre-cors), and // `cors()` itself handles OPTIONS preflights for every path. Registering a @@ -957,7 +960,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Rate-limited (CodeQL js/missing-rate-limiting): destructive operation // doing fs.rm of clone + storage dirs. Default 60 rpm/IP is generous for // delete; tighten if abuse is observed. - app.delete('/api/repo', createRouteLimiter(), async (req, res) => { + app.delete('/api/repo', createRouteLimiter(), requireLocalhostOrigin, async (req, res) => { try { const repoName = requestedRepo(req); if (!repoName) { @@ -1480,10 +1483,12 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // slashes, so it is dropped. Analyzing a local path the operator names // is the tool's intended capability (same as the CLI); the dangerous // part was cross-origin reach, which is closed by requireLocalhostOrigin - // on this route. We only require an absolute path here and let the - // analyze worker surface a clear error if it does not exist. (We do NOT - // realpath/stat the path in-route: that would be a user-controlled - // filesystem read — CodeQL js/path-injection — for no security gain.) + // on this route (scoped to the server's own bound host — other LAN + // devices are NOT trusted). We only require an absolute path here and + // let the analyze worker surface a clear error if it does not exist. + // (We do NOT realpath/stat the path in-route: that would be a + // user-controlled filesystem read — CodeQL js/path-injection — for no + // security gain.) if (repoLocalPath && !path.isAbsolute(repoLocalPath)) { res.status(400).json({ error: '"path" must be an absolute path' }); return; @@ -1586,8 +1591,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => mountSSEProgress(app, '/api/analyze/:jobId/progress', jobManager); // DELETE /api/analyze/:jobId — cancel a running analysis job - app.delete('/api/analyze/:jobId', (req, res) => { - const job = jobManager.getJob(req.params.jobId); + app.delete('/api/analyze/:jobId', requireLocalhostOrigin, (req, res) => { + const jobId = req.params.jobId as string; + const job = jobManager.getJob(jobId); if (!job) { res.status(404).json({ error: 'Job not found' }); return; @@ -1596,7 +1602,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.status(400).json({ error: `Job already ${job.status}` }); return; } - jobManager.cancelJob(req.params.jobId, 'Cancelled by user'); + jobManager.cancelJob(jobId, 'Cancelled by user'); res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' }); }); @@ -1605,122 +1611,127 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => const embedJobManager = new JobManager(); // POST /api/embed — trigger server-side embedding generation - app.post('/api/embed', createRouteLimiter({ limit: 20 }), async (req, res) => { - try { - const entry = await resolveRepo(requestedRepo(req)); - if (!entry) { - res.status(404).json({ error: 'Repository not found' }); - return; - } - - // Check shared repo lock — prevent concurrent analyze + embed on same repo - const repoLockPath = entry.storagePath; - const lockErr = acquireRepoLock(repoLockPath); - if (lockErr) { - res.status(409).json({ error: lockErr }); - return; - } - - const job = embedJobManager.createJob({ repoPath: entry.storagePath }); - embedJobManager.updateJob(job.id, { - repoName: entry.name, - status: 'analyzing' as any, - progress: { phase: 'analyzing', percent: 0, message: 'Starting embedding generation...' }, - }); - - // 30-minute timeout for embedding jobs (same as analyze jobs) - const EMBED_TIMEOUT_MS = 30 * 60 * 1000; - const embedTimeout = setTimeout(() => { - const current = embedJobManager.getJob(job.id); - if (current && current.status !== 'complete' && current.status !== 'failed') { - releaseRepoLock(repoLockPath); - embedJobManager.updateJob(job.id, { - status: 'failed', - error: 'Embedding timed out (30 minute limit)', - }); + app.post( + '/api/embed', + createRouteLimiter({ limit: 20 }), + requireLocalhostOrigin, + async (req, res) => { + try { + const entry = await resolveRepo(requestedRepo(req)); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; } - }, EMBED_TIMEOUT_MS); - // Run embedding pipeline asynchronously - (async () => { - try { - const lbugPath = path.join(entry.storagePath, 'lbug'); - await withLbugDb(lbugPath, async () => { - const { runEmbeddingPipeline } = - await import('../core/embeddings/embedding-pipeline.js'); - // Fetch existing content hashes for incremental embedding. - // Delegated to lbug-adapter which owns the DB query logic and legacy-fallback handling. - const { fetchExistingEmbeddingHashes } = await import('../core/lbug/lbug-adapter.js'); - const existingEmbeddings = await fetchExistingEmbeddingHashes(executeQuery); - if (existingEmbeddings && existingEmbeddings.size > 0) { - console.log( - `[embed] ${existingEmbeddings.size} nodes already embedded — incremental run with content-hash comparison`, - ); - } - await runEmbeddingPipeline( - executeQuery, - executeWithReusedStatement, - (p) => { - embedJobManager.updateJob(job.id, { - progress: { - phase: - p.phase === 'ready' ? 'complete' : p.phase === 'error' ? 'failed' : p.phase, - percent: p.percent, - message: - p.phase === 'loading-model' - ? 'Loading embedding model...' - : p.phase === 'embedding' - ? `Embedding nodes (${p.percent}%)...` - : p.phase === 'indexing' - ? 'Creating vector index...' - : p.phase === 'ready' - ? 'Embeddings complete' - : `${p.phase} (${p.percent}%)`, - }, - }); - }, - {}, // config: use defaults - undefined, // skipNodeIds - undefined, // context - existingEmbeddings, - ); + // Check shared repo lock — prevent concurrent analyze + embed on same repo + const repoLockPath = entry.storagePath; + const lockErr = acquireRepoLock(repoLockPath); + if (lockErr) { + res.status(409).json({ error: lockErr }); + return; + } - // Flush WAL so subsequent /api/search requests see the new - // embeddings immediately (#1149). In the CLI path closeLbug() - // handles this during process exit, but the server keeps the - // connection open for other routes — a CHECKPOINT is enough. - await flushWAL(); - }); + const job = embedJobManager.createJob({ repoPath: entry.storagePath }); + embedJobManager.updateJob(job.id, { + repoName: entry.name, + status: 'analyzing' as any, + progress: { phase: 'analyzing', percent: 0, message: 'Starting embedding generation...' }, + }); - clearTimeout(embedTimeout); - releaseRepoLock(repoLockPath); - // Don't overwrite 'failed' if the job was cancelled while the pipeline was running + // 30-minute timeout for embedding jobs (same as analyze jobs) + const EMBED_TIMEOUT_MS = 30 * 60 * 1000; + const embedTimeout = setTimeout(() => { const current = embedJobManager.getJob(job.id); - if (!current || current.status !== 'failed') { - embedJobManager.updateJob(job.id, { status: 'complete' }); - } - } catch (err: any) { - clearTimeout(embedTimeout); - releaseRepoLock(repoLockPath); - const current = embedJobManager.getJob(job.id); - if (!current || current.status !== 'failed') { + if (current && current.status !== 'complete' && current.status !== 'failed') { + releaseRepoLock(repoLockPath); embedJobManager.updateJob(job.id, { status: 'failed', - error: err.message || 'Embedding generation failed', + error: 'Embedding timed out (30 minute limit)', }); } - } - })(); + }, EMBED_TIMEOUT_MS); - res.status(202).json({ jobId: job.id, status: 'analyzing' }); - } catch (err: any) { - if (err.message?.includes('already in progress')) { - res.status(409).json({ error: err.message }); - } else { - res.status(500).json({ error: err.message || 'Failed to start embedding generation' }); + // Run embedding pipeline asynchronously + (async () => { + try { + const lbugPath = path.join(entry.storagePath, 'lbug'); + await withLbugDb(lbugPath, async () => { + const { runEmbeddingPipeline } = + await import('../core/embeddings/embedding-pipeline.js'); + // Fetch existing content hashes for incremental embedding. + // Delegated to lbug-adapter which owns the DB query logic and legacy-fallback handling. + const { fetchExistingEmbeddingHashes } = await import('../core/lbug/lbug-adapter.js'); + const existingEmbeddings = await fetchExistingEmbeddingHashes(executeQuery); + if (existingEmbeddings && existingEmbeddings.size > 0) { + console.log( + `[embed] ${existingEmbeddings.size} nodes already embedded — incremental run with content-hash comparison`, + ); + } + await runEmbeddingPipeline( + executeQuery, + executeWithReusedStatement, + (p) => { + embedJobManager.updateJob(job.id, { + progress: { + phase: + p.phase === 'ready' ? 'complete' : p.phase === 'error' ? 'failed' : p.phase, + percent: p.percent, + message: + p.phase === 'loading-model' + ? 'Loading embedding model...' + : p.phase === 'embedding' + ? `Embedding nodes (${p.percent}%)...` + : p.phase === 'indexing' + ? 'Creating vector index...' + : p.phase === 'ready' + ? 'Embeddings complete' + : `${p.phase} (${p.percent}%)`, + }, + }); + }, + {}, // config: use defaults + undefined, // skipNodeIds + undefined, // context + existingEmbeddings, + ); + + // Flush WAL so subsequent /api/search requests see the new + // embeddings immediately (#1149). In the CLI path closeLbug() + // handles this during process exit, but the server keeps the + // connection open for other routes — a CHECKPOINT is enough. + await flushWAL(); + }); + + clearTimeout(embedTimeout); + releaseRepoLock(repoLockPath); + // Don't overwrite 'failed' if the job was cancelled while the pipeline was running + const current = embedJobManager.getJob(job.id); + if (!current || current.status !== 'failed') { + embedJobManager.updateJob(job.id, { status: 'complete' }); + } + } catch (err: any) { + clearTimeout(embedTimeout); + releaseRepoLock(repoLockPath); + const current = embedJobManager.getJob(job.id); + if (!current || current.status !== 'failed') { + embedJobManager.updateJob(job.id, { + status: 'failed', + error: err.message || 'Embedding generation failed', + }); + } + } + })(); + + res.status(202).json({ jobId: job.id, status: 'analyzing' }); + } catch (err: any) { + if (err.message?.includes('already in progress')) { + res.status(409).json({ error: err.message }); + } else { + res.status(500).json({ error: err.message || 'Failed to start embedding generation' }); + } } - } - }); + }, + ); // GET /api/embed/:jobId — poll embedding job status app.get('/api/embed/:jobId', (req, res) => { @@ -1744,8 +1755,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => mountSSEProgress(app, '/api/embed/:jobId/progress', embedJobManager); // DELETE /api/embed/:jobId — cancel embedding job - app.delete('/api/embed/:jobId', (req, res) => { - const job = embedJobManager.getJob(req.params.jobId); + app.delete('/api/embed/:jobId', requireLocalhostOrigin, (req, res) => { + const jobId = req.params.jobId as string; + const job = embedJobManager.getJob(jobId); if (!job) { res.status(404).json({ error: 'Job not found' }); return; @@ -1754,7 +1766,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.status(400).json({ error: `Job already ${job.status}` }); return; } - embedJobManager.cancelJob(req.params.jobId, 'Cancelled by user'); + embedJobManager.cancelJob(jobId, 'Cancelled by user'); res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' }); }); diff --git a/gitnexus/src/server/middleware.ts b/gitnexus/src/server/middleware.ts index 73d3edab7..286d0b337 100644 --- a/gitnexus/src/server/middleware.ts +++ b/gitnexus/src/server/middleware.ts @@ -5,25 +5,91 @@ import type { Request, Response } from 'express'; /** - * Restrict a route to localhost browser origins. Non-browser requests (no - * Origin header, e.g. curl / the CLI) pass through. This closes cross-origin - * reach (the allow-listed public deploy + Private Network Access) to write - * routes without affecting read routes. + * Canonicalize a bound-host string into the form a browser `Origin` hostname + * takes after WHATWG URL parsing, so the same-host comparison in + * {@link createLocalhostOriginGuard} can use a plain `===`. + * + * Returns `undefined` when the host carries no single comparable identity: + * - empty / not provided + * - a wildcard bind (`0.0.0.0`, `::`, expanded `0:0:0:0:0:0:0:0`) — the server + * listens on every interface and has no one address a browser Origin maps to, + * so writes stay loopback-only (we deliberately do NOT trust the whole subnet) + * - an unparseable value + * + * Otherwise returns `new URL(...).hostname` (lowercased, IPv6 bracketed and + * compressed) — provably identical to how the request Origin is parsed below. + * Hand-rolling lowercase + bracketing is insufficient: it fails to compress + * non-canonical IPv6 forms (e.g. `fe80:0:0:0:0:0:0:1`, `::ffff:127.0.0.1`). */ -export function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void { - const origin = req.headers.origin; - if (origin === undefined) { - next(); - return; - } +export function normalizeBoundHost(boundHost?: string): string | undefined { + if (!boundHost) return undefined; + // Bracket a bare IPv6 literal so `new URL` can parse it as a host. + const candidate = + boundHost.includes(':') && !boundHost.startsWith('[') ? `[${boundHost}]` : boundHost; + let hostname: string; try { - const hostname = new URL(origin).hostname; - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { + hostname = new URL(`http://${candidate}`).hostname; + } catch { + return undefined; + } + // Wildcard binds have no single host identity → keep writes loopback-only. + if (hostname === '' || hostname === '0.0.0.0' || hostname === '[::]') { + return undefined; + } + return hostname; +} + +/** + * Restrict a route to same-host browser origins. Allows: + * - loopback (`localhost`, `127.0.0.1`, `[::1]`) + * - the server's own bound host (when non-loopback, e.g. a LAN IP) + * + * Non-browser requests (no Origin header, e.g. curl / the CLI) pass through. + * This closes cross-origin reach to write routes without affecting read routes. + * + * @param boundHost - The hostname/IP the server is listening on (from + * `createServer`'s `host` parameter). When `undefined`, `'localhost'`, or a + * wildcard (`0.0.0.0`/`::`), only loopback origins are admitted. + */ +export function createLocalhostOriginGuard(boundHost?: string) { + const normalizedBoundHost = normalizeBoundHost(boundHost); + return function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void { + const origin = req.headers.origin; + if (origin === undefined) { next(); return; } - } catch { - /* malformed origin → reject */ - } - res.status(403).json({ error: 'This endpoint is restricted to localhost origins' }); + try { + const parsed = new URL(origin); + const hostname = parsed.hostname; + const protocol = parsed.protocol; + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error('Unsupported origin protocol'); + } + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') { + next(); + return; + } + // Allow origin matching the server's own bound host (same-host check). + // `normalizedBoundHost` is canonicalized to the WHATWG form `hostname` + // already carries; it is `undefined` for wildcard/no binds (loopback-only). + // This covers the case where the operator runs `gitnexus serve --host `. + if (normalizedBoundHost && hostname === normalizedBoundHost) { + next(); + return; + } + } catch { + /* malformed origin → reject */ + } + res.status(403).json({ + error: 'This endpoint is restricted to same-host origins', + code: 'origin_not_allowed', + }); + }; } + +/** + * Default guard that only allows loopback origins. For use in tests or when + * the bound host is not available. + */ +export const requireLocalhostOrigin = createLocalhostOriginGuard(); diff --git a/gitnexus/src/server/private-ip.ts b/gitnexus/src/server/private-ip.ts new file mode 100644 index 000000000..ac9a37fd0 --- /dev/null +++ b/gitnexus/src/server/private-ip.ts @@ -0,0 +1,13 @@ +const parseIpv4Octets = (hostname: string): number[] | null => { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return null; + const octets = hostname.split('.').map(Number); + if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null; + return octets; +}; + +export const isRfc1918PrivateIpv4 = (hostname: string): boolean => { + const octets = parseIpv4Octets(hostname); + if (octets === null) return false; + const [a, b] = octets; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +}; diff --git a/gitnexus/test/unit/api-analyze-upload.test.ts b/gitnexus/test/unit/api-analyze-upload.test.ts index af3bbfd23..8cc1167c0 100644 --- a/gitnexus/test/unit/api-analyze-upload.test.ts +++ b/gitnexus/test/unit/api-analyze-upload.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs/promises'; import { Readable } from 'node:stream'; import type { IncomingMessage } from 'node:http'; import { createAnalyzeUploadHandler } from '../../src/server/analyze-upload.js'; -import { requireLocalhostOrigin } from '../../src/server/middleware.js'; +import { requireLocalhostOrigin, createLocalhostOriginGuard } from '../../src/server/middleware.js'; const BOUNDARY = '----gitnexusuploadtest'; @@ -275,9 +275,10 @@ describe('requireLocalhostOrigin', () => { return { passed, status }; } - it('passes localhost / 127.0.0.1 / no-origin', () => { + it('passes localhost / 127.0.0.1 / [::1] / no-origin', () => { expect(call('http://localhost:5173').passed).toBe(true); expect(call('http://127.0.0.1:4747').passed).toBe(true); + expect(call('http://[::1]:4747').passed).toBe(true); expect(call(undefined).passed).toBe(true); }); @@ -286,4 +287,100 @@ describe('requireLocalhostOrigin', () => { expect(r.passed).toBe(false); expect(r.status).toBe(403); }); + + it('rejects RFC1918 origins when no boundHost is set (default guard)', () => { + expect(call('http://10.0.0.1:4173').passed).toBe(false); + expect(call('http://172.16.1.21:4173').passed).toBe(false); + expect(call('http://192.168.1.100:4173').passed).toBe(false); + }); + + it('rejects malformed and non-private hostnames with 403', () => { + expect(call('http://my-local-server.local:4173').passed).toBe(false); + expect(call('ftp://localhost:4173').passed).toBe(false); + expect(call('null').passed).toBe(false); + }); +}); + +describe('createLocalhostOriginGuard (bound host)', () => { + function callWith( + boundHost: string, + origin: string | undefined, + ): { passed: boolean; status: number; body?: { error?: string; code?: string } } { + const guard = createLocalhostOriginGuard(boundHost); + let passed = false; + let status = 0; + let body: { error?: string; code?: string } | undefined; + const req = { headers: origin === undefined ? {} : { origin } } as never; + const res = { + status: (c: number) => { + status = c; + return { + json: (b: { error?: string; code?: string }) => { + body = b; + }, + }; + }, + } as never; + guard(req, res, () => { + passed = true; + }); + return { passed, status, body }; + } + + it('allows origin matching the bound host', () => { + expect(callWith('192.168.1.100', 'http://192.168.1.100:4747').passed).toBe(true); + expect(callWith('10.0.0.5', 'http://10.0.0.5:4173').passed).toBe(true); + expect(callWith('172.16.1.21', 'http://172.16.1.21:4173').passed).toBe(true); + }); + + it('still allows loopback regardless of bound host', () => { + expect(callWith('192.168.1.100', 'http://localhost:5173').passed).toBe(true); + expect(callWith('192.168.1.100', 'http://127.0.0.1:4747').passed).toBe(true); + expect(callWith('192.168.1.100', 'http://[::1]:4747').passed).toBe(true); + }); + + it('normalizes mixed-case host binds to match the WHATWG origin hostname', () => { + // WHATWG lowercases the Origin hostname; boundHost must canonicalize the same way. + expect(callWith('MyHost.local', 'http://myhost.local:4747').passed).toBe(true); + }); + + it('normalizes IPv6 host binds (compressed + non-canonical) to match the origin', () => { + expect(callWith('fe80::1', 'http://[fe80::1]:4747').passed).toBe(true); + // Expanded form must compress to the same WHATWG hostname as the origin. + expect(callWith('fe80:0:0:0:0:0:0:1', 'http://[fe80::1]:4747').passed).toBe(true); + // Already-bracketed input is idempotent. + expect(callWith('[fe80::1]', 'http://[fe80::1]:4747').passed).toBe(true); + }); + + it('keeps wildcard binds (0.0.0.0 / :: / expanded) loopback-only', () => { + // No browser Origin equals a wildcard, so non-loopback writes are rejected... + expect(callWith('0.0.0.0', 'http://192.168.1.5:4747').passed).toBe(false); + expect(callWith('::', 'http://[fe80::1]:4747').passed).toBe(false); + expect(callWith('0:0:0:0:0:0:0:0', 'http://[fe80::1]:4747').passed).toBe(false); + // ...while loopback still passes under a wildcard bind. + expect(callWith('0.0.0.0', 'http://localhost:5173').passed).toBe(true); + expect(callWith('::', 'http://127.0.0.1:4747').passed).toBe(true); + }); + + it('rejects other RFC1918 origins that do not match bound host', () => { + expect(callWith('192.168.1.100', 'http://192.168.1.101:4747').passed).toBe(false); + expect(callWith('192.168.1.100', 'http://10.0.0.1:4747').passed).toBe(false); + expect(callWith('10.0.0.5', 'http://172.16.1.21:4747').passed).toBe(false); + }); + + it('rejects public origins even when bound to LAN', () => { + const r = callWith('192.168.1.100', 'https://gitnexus.vercel.app'); + expect(r.passed).toBe(false); + expect(r.status).toBe(403); + }); + + it('tags the rejection 403 with a machine-readable code', () => { + const r = callWith('192.168.1.100', 'https://gitnexus.vercel.app'); + expect(r.status).toBe(403); + expect(r.body?.code).toBe('origin_not_allowed'); + }); + + it('passes no-origin (non-browser) requests', () => { + expect(callWith('192.168.1.100', undefined).passed).toBe(true); + }); }); diff --git a/gitnexus/test/unit/private-ip.test.ts b/gitnexus/test/unit/private-ip.test.ts new file mode 100644 index 000000000..5e1374167 --- /dev/null +++ b/gitnexus/test/unit/private-ip.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { isRfc1918PrivateIpv4 } from '../../src/server/private-ip.js'; + +describe('isRfc1918PrivateIpv4', () => { + it('accepts 10.0.0.0/8 range', () => { + expect(isRfc1918PrivateIpv4('10.0.0.0')).toBe(true); + expect(isRfc1918PrivateIpv4('10.255.255.255')).toBe(true); + expect(isRfc1918PrivateIpv4('10.1.2.3')).toBe(true); + }); + + it('accepts 172.16.0.0/12 range', () => { + expect(isRfc1918PrivateIpv4('172.16.0.0')).toBe(true); + expect(isRfc1918PrivateIpv4('172.31.255.255')).toBe(true); + expect(isRfc1918PrivateIpv4('172.20.1.1')).toBe(true); + }); + + it('rejects 172.x outside /12 range', () => { + expect(isRfc1918PrivateIpv4('172.15.255.255')).toBe(false); + expect(isRfc1918PrivateIpv4('172.32.0.0')).toBe(false); + }); + + it('accepts 192.168.0.0/16 range', () => { + expect(isRfc1918PrivateIpv4('192.168.0.0')).toBe(true); + expect(isRfc1918PrivateIpv4('192.168.255.255')).toBe(true); + expect(isRfc1918PrivateIpv4('192.168.1.100')).toBe(true); + }); + + it('rejects 192.x outside /16 range', () => { + expect(isRfc1918PrivateIpv4('192.167.1.1')).toBe(false); + expect(isRfc1918PrivateIpv4('192.169.1.1')).toBe(false); + }); + + it('rejects public IPs', () => { + expect(isRfc1918PrivateIpv4('8.8.8.8')).toBe(false); + expect(isRfc1918PrivateIpv4('1.1.1.1')).toBe(false); + expect(isRfc1918PrivateIpv4('203.0.113.1')).toBe(false); + }); + + it('rejects non-IPv4 input', () => { + expect(isRfc1918PrivateIpv4('localhost')).toBe(false); + expect(isRfc1918PrivateIpv4('[::1]')).toBe(false); + expect(isRfc1918PrivateIpv4('')).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 285a8f8c3..2f52e9df8 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -247,7 +247,9 @@ describe('production routes — rate-limit middleware wiring', () => { }); it('POST /api/embed is wired with createRouteLimiter', () => { - expect(apiSource).toMatch(/app\.post\('\/api\/embed',\s*createRouteLimiter\(/); + // Tolerate Prettier wrapping the registration across lines (it does once + // the route carries extra middleware like requireLocalhostOrigin). + expect(apiSource).toMatch(/app\.post\(\s*'\/api\/embed',\s*createRouteLimiter\(/); }); it('SPA fallback is wired with createRouteLimiter', () => {