mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +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>
386 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|