GitNexus/gitnexus/test/unit/api-analyze-upload.test.ts
Copilot 60752de3e9
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>
2026-06-13 09:24:03 +01:00

386 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
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, createLocalhostOriginGuard } from '../../src/server/middleware.js';
const BOUNDARY = '----gitnexusuploadtest';
function multipart(
parts: Array<{ name: string; value?: string; filename?: string; data?: Buffer }>,
): { body: Buffer; headers: Record<string, string> } {
const chunks: Buffer[] = [];
for (const p of parts) {
chunks.push(Buffer.from(`--${BOUNDARY}\r\n`));
if (p.filename !== undefined) {
chunks.push(
Buffer.from(
`Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
),
);
chunks.push(p.data ?? Buffer.alloc(0));
chunks.push(Buffer.from('\r\n'));
} else {
chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"\r\n\r\n`));
chunks.push(Buffer.from(p.value ?? ''));
chunks.push(Buffer.from('\r\n'));
}
}
chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`));
return {
body: Buffer.concat(chunks),
headers: { 'content-type': `multipart/form-data; boundary=${BOUNDARY}` },
};
}
function mockReq(parts: Parameters<typeof multipart>[0]): IncomingMessage {
const { body, headers } = multipart(parts);
const r = Readable.from([body]) as unknown as IncomingMessage & { headers: typeof headers };
r.headers = headers;
return r;
}
interface MockRes {
statusCode: number;
body: unknown;
status: (c: number) => MockRes;
json: (b: unknown) => MockRes;
}
function mockRes(): MockRes {
const res = { statusCode: 0, body: undefined as unknown } as MockRes;
res.status = (c: number) => {
res.statusCode = c;
return res;
};
res.json = (b: unknown) => {
res.body = b;
return res;
};
return res;
}
// Track promoted upload dirs created by the real ingest+promote so we clean up.
const promoted: string[] = [];
afterEach(async () => {
while (promoted.length) {
await fs.rm(promoted.pop()!, { recursive: true, force: true }).catch(() => {});
}
});
function uniqueTop(): string {
return `uptest_${Math.floor(Math.random() * 1e9).toString(36)}`;
}
describe('createAnalyzeUploadHandler', () => {
it('ingests, promotes the inner folder, and launches analysis (202)', async () => {
const top = uniqueTop();
const createJob = vi.fn(() => ({ id: 'job-1', status: 'queued' }));
const launch = vi.fn((_j, dir: string) => promoted.push(dir));
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify([`${top}/a.js`, `${top}/sub/b.js`]) },
{ name: 'files', filename: 'blob', data: Buffer.from('alpha') },
{ name: 'files', filename: 'blob', data: Buffer.from('beta') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(202);
expect((res.body as { jobId: string }).jobId).toBe('job-1');
expect(createJob).toHaveBeenCalledOnce();
expect(launch).toHaveBeenCalledOnce();
const dir = launch.mock.calls[0][1] as string;
const opts = launch.mock.calls[0][2] as { registryName: string };
// Inner folder promoted: contents live directly under the upload dir.
expect(await fs.readFile(path.join(dir, 'a.js'), 'utf8')).toBe('alpha');
expect(await fs.readFile(path.join(dir, 'sub', 'b.js'), 'utf8')).toBe('beta');
expect(opts.registryName).toBe(top);
expect(createJob.mock.calls[0][0].repoPath).toBe(dir);
});
it('maps a busy job (createJob throws "already in progress") to 409 and promotes nothing', async () => {
const top = uniqueTop();
const createJob = vi.fn(() => {
throw new Error('Analysis already in progress for another repository');
});
const launch = vi.fn((_j, dir: string) => promoted.push(dir));
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify([`${top}/a.js`]) },
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(409);
expect(launch).not.toHaveBeenCalled();
// Nothing promoted onto disk.
const { UPLOAD_ROOT } = await import('../../src/server/upload-paths.js');
await expect(fs.access(path.join(UPLOAD_ROOT, top))).rejects.toBeTruthy();
});
it('rejects a traversal path in the manifest (400) without launching', async () => {
const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
const launch = vi.fn();
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify(['../escape.js']) },
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(400);
expect(createJob).not.toHaveBeenCalled();
expect(launch).not.toHaveBeenCalled();
});
it('rejects an un-nameable top folder (Windows-reserved → 400)', async () => {
const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
const launch = vi.fn();
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify(['CON/a.js']) },
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(400);
expect(launch).not.toHaveBeenCalled();
});
it('strips a crafted .gitnexus index from the promoted upload', async () => {
const top = uniqueTop();
const createJob = vi.fn(() => ({ id: 'job-x', status: 'queued' }));
const launch = vi.fn((_j, dir: string) => promoted.push(dir));
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify([`${top}/.gitnexus/meta.json`, `${top}/a.js`]) },
{ name: 'files', filename: 'blob', data: Buffer.from('{"evil":true}') },
{ name: 'files', filename: 'blob', data: Buffer.from('real') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(202);
const dir = launch.mock.calls[0][1] as string;
await expect(fs.access(path.join(dir, '.gitnexus'))).rejects.toBeTruthy();
expect(await fs.readFile(path.join(dir, 'a.js'), 'utf8')).toBe('real');
});
it('rejects a single-segment manifest before creating a job (no slot taken)', async () => {
const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
const launch = vi.fn();
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify(['loosefile.js']) },
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(400);
expect(createJob).not.toHaveBeenCalled(); // slot never taken → no wedge
expect(launch).not.toHaveBeenCalled();
});
it('rejects a multi-top-folder manifest (would silently drop folders)', async () => {
const createJob = vi.fn(() => ({ id: 'j', status: 'queued' }));
const launch = vi.fn();
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify(['aaa/x.js', 'bbb/y.js']) },
{ name: 'files', filename: 'blob', data: Buffer.from('1') },
{ name: 'files', filename: 'blob', data: Buffer.from('2') },
]) as never,
res as never,
);
expect(res.statusCode).toBe(400);
expect(createJob).not.toHaveBeenCalled();
});
it('releases the single slot (failJob) when a step fails after createJob', async () => {
const top = uniqueTop();
const createJob = vi.fn(() => ({ id: 'job-fail', status: 'queued' }));
// launch throws AFTER createJob + promote — the slot must be released.
const launch = vi.fn((_j, dir: string) => {
promoted.push(dir);
throw new Error('worker fork blew up');
});
const failJob = vi.fn();
const handler = createAnalyzeUploadHandler({ createJob, launch, failJob });
const res = mockRes();
await handler(
mockReq([
{ name: 'manifest', value: JSON.stringify([`${top}/a.js`]) },
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
]) as never,
res as never,
);
expect(createJob).toHaveBeenCalledOnce();
expect(failJob).toHaveBeenCalledWith('job-fail', expect.any(String));
expect(res.statusCode).toBe(500);
});
});
describe('requireLocalhostOrigin', () => {
function call(origin: string | undefined): { passed: boolean; status: number } {
let passed = false;
let status = 0;
const req = { headers: origin === undefined ? {} : { origin } } as never;
const res = {
status: (c: number) => {
status = c;
return { json: () => {} };
},
} as never;
requireLocalhostOrigin(req, res, () => {
passed = true;
});
return { passed, status };
}
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);
});
it('rejects a public/cross origin with 403', () => {
const r = call('https://gitnexus.vercel.app');
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);
});
});