mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
/**
|
|
* Method-aware retry budget + timeout-as-TimeoutError verification for
|
|
* backend-client's `fetchWithTimeout`.
|
|
*
|
|
* Closes review findings on PR #1448:
|
|
* - Non-idempotent POST/DELETE must NOT be retried by default —
|
|
* a 5xx on `startAnalyze` could otherwise start a duplicate job.
|
|
* - Timer-fired timeout must surface as `DOMException(name='TimeoutError')`,
|
|
* not `AbortError`, so resilientFetch routes it through the
|
|
* terminal-network branch (no retry, no breaker hit).
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getBreaker } from 'gitnexus-shared';
|
|
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
|
import {
|
|
deleteRepo,
|
|
fetchRepos,
|
|
setBackendUrl,
|
|
startAnalyze,
|
|
} from '../../src/services/backend-client';
|
|
|
|
const BASE = 'http://localhost:4747';
|
|
|
|
describe('backend-client retry budget (method-aware)', () => {
|
|
beforeEach(() => {
|
|
__resetBreakerRegistry__();
|
|
setBackendUrl(BASE);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('GET retries once on transient 503 (idempotent verb)', async () => {
|
|
let n = 0;
|
|
const fetchMock = vi.fn(async () => {
|
|
n += 1;
|
|
if (n === 1) return new Response('boom', { status: 503 });
|
|
return new Response('[]', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const repos = await fetchRepos();
|
|
expect(repos).toEqual([]);
|
|
// 1 retry budget on idempotent GET → 2 total fetch calls.
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('POST does NOT retry on 503 by default (non-idempotent verb)', async () => {
|
|
const fetchMock = vi.fn(async () => new Response('boom', { status: 503 }));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(startAnalyze({ path: '/tmp/repo' })).rejects.toBeTruthy();
|
|
// Single attempt — never duplicates a job-start POST.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('switching backend URL after a circuit opens reaches a fresh breaker (U3)', async () => {
|
|
// Pre-open the breaker for host-A by directly recording 3 failures.
|
|
setBackendUrl('http://host-a.test:4747');
|
|
const aKey = 'web-backend:http://host-a.test:4747';
|
|
const breakerA = getBreaker(aKey);
|
|
breakerA.recordFailure();
|
|
breakerA.recordFailure();
|
|
breakerA.recordFailure();
|
|
expect(breakerA.getState()).toBe('open');
|
|
|
|
// Switch to host-B and make a request — must succeed against the
|
|
// new origin without tripping the host-A circuit. Under the old
|
|
// single-key behaviour the call would throw CircuitOpenError.
|
|
setBackendUrl('http://host-b.test:4747');
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response('[]', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const repos = await fetchRepos();
|
|
expect(repos).toEqual([]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Host-A's breaker is still open in cooldown.
|
|
expect(breakerA.getState()).toBe('open');
|
|
// Host-B has its own (fresh) breaker.
|
|
const bKey = 'web-backend:http://host-b.test:4747';
|
|
expect(getBreaker(bKey).getState()).toBe('closed');
|
|
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
|
|
// fires. The real-fetch path goes signal.reason → reject(reason);
|
|
// we shortcut that here so the test doesn't have to wait the
|
|
// 30-second default timeout.
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new DOMException('aborted by timeout', 'TimeoutError');
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(fetchRepos()).rejects.toMatchObject({ code: 'timeout' });
|
|
|
|
// The breaker must not have been penalized for a local timeout.
|
|
expect(getBreaker(`web-backend:${BASE}`).getConsecutiveFailures()).toBe(0);
|
|
// Timeout is terminal — no retry attempted.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|