mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
- CI: add a 'test' job running the unit suite via config/vitest.ci.config.ts. Excludes browser (Playwright/chromium) and perf tests (timing-flaky), like the existing test/mobile suite. Safe in CI: TmuxManager no-ops shell commands under VITEST (test/setup.ts). - Add scripts/check-frontend-syntax.mjs (node --check on src/web/public/*.js), wired into the lint job — catches a class of frontend SyntaxError that passes lint today (lint globs only TS). - Add test/security-regression.test.ts (wired Host/Origin guard, self-update CSRF, CSP/security headers, text/plain raw body, WS anti-CSWSH) + test/sse-registry-parity.test.ts (backend<->frontend SSE registry parity). - Green pre-existing test debt surfaced by the new gate: stale 'Session not found' asserts -> 'not found' substring; drop tests for removed helpers (isError now internal; createSuccessResponse deleted); file-stream-manager: mock realpathSync + fix stale /tmp assertion; sse-subscription-filter: lifecycle events broadcast to all clients (only terminal stream filtered); session.test.ts: mkdir /tmp/test; skip one interactive-respawn test needing a real PTY (covered by respawn-controller.test.ts). - Full non-mobile suite verified green locally (2680 passed, 12 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
6.5 KiB
TypeScript
168 lines
6.5 KiB
TypeScript
/**
|
|
* Security regression tests for the 2026-06-09 hardening (v0.9.5).
|
|
*
|
|
* These assert the fixes as WIRED into the running Fastify server — complementing
|
|
* the pure-function coverage in network-host-guard.test.ts. A regression that
|
|
* unwires the guard (or drops a header) would pass the pure-function tests but
|
|
* fail here. Covers:
|
|
* - Host-header allowlist (anti DNS-rebinding) — onRequest, before routing
|
|
* - cross-site Origin/CSRF guard on state-changing methods (incl. self-update)
|
|
* - security response headers (CSP, X-Frame-Options, X-Content-Type-Options; HSTS gated on https)
|
|
* - text/plain bodies kept RAW (the closed "simple request" CSRF vector)
|
|
* - WebSocket anti-CSWSH (Origin/Host validated on upgrade → close 4003)
|
|
*
|
|
* Port: 3167
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import http from 'node:http';
|
|
import WebSocket from 'ws';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { TmuxManager } from '../src/tmux-manager.js';
|
|
|
|
const PORT = 3167;
|
|
|
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
|
|
|
interface RawResponse {
|
|
status: number;
|
|
headers: http.IncomingHttpHeaders;
|
|
body: string;
|
|
}
|
|
|
|
/** Raw HTTP request with full control over Host/Origin headers (fetch/undici rewrites Host). */
|
|
function raw(method: string, path: string, headers: Record<string, string> = {}, body?: string): Promise<RawResponse> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request({ host: '127.0.0.1', port: PORT, path, method, headers }, (res) => {
|
|
let data = '';
|
|
res.on('data', (c) => (data += c));
|
|
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }));
|
|
});
|
|
req.on('error', reject);
|
|
if (body !== undefined) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/** Open a WS to `path` with optional Origin and resolve with the close code the server sends. */
|
|
function wsCloseCode(path: string, origin?: string): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, {
|
|
headers: origin ? { origin } : {},
|
|
});
|
|
const timer = setTimeout(() => {
|
|
try {
|
|
ws.terminate();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
reject(new Error('WS did not close within timeout'));
|
|
}, 8000);
|
|
ws.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
resolve(code);
|
|
});
|
|
ws.on('error', () => {
|
|
/* a close frame with the code follows; let the close handler resolve */
|
|
});
|
|
});
|
|
}
|
|
|
|
let server: WebServer;
|
|
|
|
beforeAll(async () => {
|
|
delete process.env.CODEMAN_PASSWORD; // guard must work even on the no-auth default
|
|
server = new WebServer(PORT, false, true);
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop();
|
|
});
|
|
|
|
describe('Host-header allowlist (anti DNS-rebinding), wired', () => {
|
|
it('rejects a rebound custom Host with 403', async () => {
|
|
const res = await raw('GET', '/api/status', { Host: 'evil.attacker.example' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toContain('host not allowed');
|
|
});
|
|
|
|
it('allows a loopback Host', async () => {
|
|
const res = await raw('GET', '/api/status', { Host: `localhost:${PORT}` });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('allows an IP-literal Host (default when none specified)', async () => {
|
|
const res = await raw('GET', '/api/status');
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('cross-site Origin / CSRF guard, wired', () => {
|
|
// Probe a non-existent route: the onRequest guard runs BEFORE routing, so a blocked
|
|
// request 403s while an allowed one falls through to 404 — no side effects either way.
|
|
const PROBE = '/api/__csrf_probe__';
|
|
|
|
it('blocks a state-changing request from a foreign Origin with 403', async () => {
|
|
const res = await raw('POST', PROBE, { Origin: 'https://evil.attacker.example' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toContain('cross-site request blocked');
|
|
});
|
|
|
|
it('allows a state-changing request with NO Origin (curl / CLI / hooks)', async () => {
|
|
const res = await raw('POST', PROBE);
|
|
expect(res.status).not.toBe(403); // 404 (route not found) — guard let it through
|
|
});
|
|
|
|
it('allows a state-changing request from a same-site Origin', async () => {
|
|
const res = await raw('POST', PROBE, { Origin: `http://localhost:${PORT}` });
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it('does NOT block safe methods (GET) from a foreign Origin', async () => {
|
|
const res = await raw('GET', '/api/status', { Origin: 'https://evil.attacker.example' });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('blocks the self-update route (POST /api/system/update) from a foreign Origin', async () => {
|
|
const res = await raw('POST', '/api/system/update', { Origin: 'https://evil.attacker.example' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toContain('cross-site request blocked');
|
|
});
|
|
});
|
|
|
|
describe('security response headers, wired', () => {
|
|
it('sets CSP, X-Frame-Options, and X-Content-Type-Options', async () => {
|
|
const res = await raw('GET', '/api/status');
|
|
expect(res.headers['content-security-policy']).toContain("default-src 'self'");
|
|
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
});
|
|
|
|
it('does NOT set HSTS over plain http (it is gated on https)', async () => {
|
|
const res = await raw('GET', '/api/status');
|
|
expect(res.headers['strict-transport-security']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('text/plain bodies are kept raw (closed simple-request CSRF vector)', () => {
|
|
it('does not auto-JSON-parse a text/plain body into a JSON route', async () => {
|
|
// Same-site (no Origin) so the CSRF guard allows it; the body is valid JSON but
|
|
// arrives as a raw STRING, so the JSON schema validation must reject it.
|
|
const res = await raw('POST', '/api/sessions', { 'Content-Type': 'text/plain' }, '{"workingDir":"/tmp"}');
|
|
expect(res.status).not.toBe(200); // not parsed as an object → validation error, no session created
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('WebSocket anti-CSWSH', () => {
|
|
it('closes a WS upgrade from a foreign Origin with code 4003', async () => {
|
|
const code = await wsCloseCode('/ws/sessions/nonexistent/terminal', 'https://evil.attacker.example');
|
|
expect(code).toBe(4003);
|
|
});
|
|
|
|
it('passes the Origin check with no Origin (then closes 4004 for the unknown session)', async () => {
|
|
const code = await wsCloseCode('/ws/sessions/nonexistent/terminal');
|
|
expect(code).toBe(4004); // reached the session lookup → origin check passed
|
|
});
|
|
});
|