mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
50cda61a43
commit
60752de3e9
12 changed files with 460 additions and 157 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"timeout": "服务器响应超时,请稍后重试。",
|
||||
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
|
||||
"notFound": "未找到请求的仓库或资源。",
|
||||
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
|
||||
"client": "请求失败:{{message}}",
|
||||
"server": "服务器错误:{{message}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
13
gitnexus/src/server/private-ip.ts
Normal file
13
gitnexus/src/server/private-ip.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
44
gitnexus/test/unit/private-ip.test.ts
Normal file
44
gitnexus/test/unit/private-ip.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue