fix(ip): Scope write-route origin guard to server's own bound host (#2172)

* Initial plan

* Allow RFC1918 LAN origins in requireLocalhostOrigin

* Harden LAN origin parsing in middleware tests

* Refactor private IPv4 checks into shared server helper

* fix: scope origin guard to server's bound host, fix [::1], guard all write routes

- P1: Replace blanket RFC1918 trust with same-host check — only the server's
  own bound host is allowed (via `createLocalhostOriginGuard(host)`), not
  every device on the LAN.
- P2: Fix dead `::1` branch — compare against `'[::1]'` (with brackets) as
  returned by WHATWG URL parser.
- P3: Update 403 message to "same-host origins" and doc comments.
- Out-of-scope: Add `requireLocalhostOrigin` to `DELETE /api/repo`,
  `POST /api/embed`, `DELETE /api/embed/:jobId`, `DELETE /api/analyze/:jobId`.
- Tests: Add [::1] regression, ftp://, null origin, direct private-ip.ts
  unit tests, and createLocalhostOriginGuard bound-host tests.

* fix: cast route params to string when middleware breaks type inference

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(test): update rate-limit test regex to match multi-line embed route registration

* fix(ip): normalize boundHost and keep wildcard binds loopback-only

The same-host write guard compared the raw `--host` string to the WHATWG
`URL.hostname` of the Origin, so it silently 403'd legitimate same-host
browser writes for several bind forms:
  - mixed-case hostnames (`MyHost.local` vs lowercased `myhost.local`)
  - non-loopback IPv6 (`fe80::1` vs bracketed `[fe80::1]`, and non-canonical
    forms like `fe80:0:0:0:0:0:0:1` / `::ffff:127.0.0.1`)
  - wildcard binds (`0.0.0.0` / `::`), the CLI-advertised remote-access config

Canonicalize boundHost once at guard construction through `new URL().hostname`
(provably the same form the Origin is parsed into), and treat wildcard binds as
having no single host identity → writes stay loopback-only. We deliberately do
NOT fall through to RFC1918 for wildcards (that would re-open whole-LAN reach).
`createServer` now warns when bound to a wildcard so a remote-access deployment
is not silently write-blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ip): tag origin-block 403 with a machine-readable code and surface it in the web client

The write-route Origin guard returned a 403 with only a human-readable
`error` string, so clients could not distinguish an origin block from any
other 403. The hosted web client (gitnexus.vercel.app driving a local
backend) swallowed the resulting failure: the repo delete button caught the
error and only `console.error`'d it, so it silently no-op'd.

- Server: add a stable `code: 'origin_not_allowed'` discriminator to the 403 body.
- Web client: `assertOk` reads `body.code` and maps `origin_not_allowed` to a new
  `BackendError` code `origin_blocked`; `formatBackendError` renders an actionable
  i18n message (en + zh-CN) instead of the generic client message.
- Header: surface the delete failure inline instead of swallowing it to console.

Scope note: the embedding-status badge (EmbeddingStatus.tsx) hides in backend
mode (its `serverBaseUrl` guard), so it is not the surface where an origin-block
embed error appears; a dedicated backend-mode embedding-error surface is deferred
with the broader hosted-UI mode-awareness follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ip): remove unused isValidIpv4Address export

`isValidIpv4Address` had no `src/` consumer — only its own test imported it.
It was a leftover from the reverted RFC1918-middleware approach (the same-host
guard now compares against a canonicalized bound host, not an IPv4 validity
check). Remove the export and its orphaned test block. `parseIpv4Octets` stays
(it feeds `isRfc1918PrivateIpv4`, which CORS `isAllowedOrigin` still uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Copilot 2026-06-13 09:24:03 +01:00 committed by GitHub
parent 50cda61a43
commit 60752de3e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 460 additions and 157 deletions

View file

@ -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<string, string> = {
@ -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<string | null>(null); // repo name being re-analyzed
const [deleteError, setDeleteError] = useState<string | null>(null); // surfaced when a delete is rejected (e.g. origin-blocked 403)
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
const reanalyzeSseRef = useRef<AbortController | null>(null);
const repoDropdownRef = useRef<HTMLDivElement>(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 = ({
</div>
)}
{/* Surfaced delete failure (e.g. origin-blocked 403) */}
{deleteError && (
<div className="px-3 py-2 text-xs text-red-400" role="alert">
{deleteError}
</div>
)}
{/* Re-analyze progress bar */}
{reanalyzing && reanalyzeProgress && (
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">

View file

@ -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':

View file

@ -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}}"
}

View file

@ -13,6 +13,7 @@
"timeout": "服务器响应超时,请稍后重试。",
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
"notFound": "未找到请求的仓库或资源。",
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747打开 GitNexus 后再继续。",
"client": "请求失败:{{message}}",
"server": "服务器错误:{{message}}"
}

View file

@ -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<void> => {
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<void> => {
} 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<void> => {
? '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.

View file

@ -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

View file

@ -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 <that-address> 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' });
});

View file

@ -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 <LAN-IP>`.
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();

View file

@ -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);
};

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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', () => {