Files
waggle-os/packages/server/tests/local/security-middleware.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

721 lines
26 KiB
TypeScript

/**
* Security Middleware Tests
*
* Tests for:
* - Security headers are present on responses
* - Rate limiter returns 429 after limit exceeded
* - Rate limiter resets after window expires
* - CSP header has expected directives
* - Vault reveal origin enforcement
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { securityMiddleware, RateLimiter, ENDPOINT_RATE_LIMITS } from '../../src/local/security-middleware.js';
// ── Helper: create a test server with security middleware ─────────────
async function createTestServer(opts?: {
rateLimiter?: { maxRequests?: number; windowMs?: number };
sessionToken?: string;
authenticateRunToken?: (token: string) => boolean;
}) {
const server = Fastify({ logger: false });
await server.register(securityMiddleware, {
rateLimiter: opts?.rateLimiter,
sessionToken: opts?.sessionToken,
authenticateRunToken: opts?.authenticateRunToken,
});
// Simple test routes
server.get('/health', async () => {
return { status: 'ok', wsToken: opts?.sessionToken ?? '' };
});
server.get('/api/test', async () => {
return { ok: true };
});
server.post('/api/test', async () => {
return { ok: true };
});
server.post('/api/chat', async () => {
return { ok: true };
});
server.post('/api/backup', async () => {
return { ok: true };
});
server.post('/api/waggle-dance/signal', async () => {
return { ok: true };
});
server.get('/api/waggle-dance/signals', async () => {
return { ok: true };
});
server.post('/api/vault/:name/reveal', async () => {
return { ok: true };
});
// Non-API GETs: the SPA shell + static assets. These must load WITHOUT a
// bearer token, else a browser can never bootstrap the token (chicken-and-egg).
server.get('/', async () => {
return '<!doctype html><html><body>waggle</body></html>';
});
server.get('/assets/app.js', async () => {
return 'console.log("app");';
});
await server.ready();
return server;
}
// ── Security Headers ────────────────────────────────────────────────────
describe('Security Headers', () => {
let server: ReturnType<typeof Fastify>;
beforeEach(async () => {
server = await createTestServer();
});
afterEach(async () => {
await server.close();
});
it('includes X-Content-Type-Options: nosniff', async () => {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('includes X-Frame-Options: DENY', async () => {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.headers['x-frame-options']).toBe('DENY');
});
it('includes X-XSS-Protection', async () => {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.headers['x-xss-protection']).toBe('1; mode=block');
});
it('includes Referrer-Policy', async () => {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
});
it('includes Content-Security-Policy with expected directives', async () => {
const res = await server.inject({ method: 'GET', url: '/api/test' });
const csp = res.headers['content-security-policy'] as string;
expect(csp).toBeDefined();
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("script-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("connect-src 'self'");
expect(csp).toContain('https://api.anthropic.com');
// P1-002: PostHog capture host allowed in connect-src ONLY (ingest), never
// script-src — the no-external posthog build keeps script-src locked.
expect(csp).toContain('connect-src');
expect(csp).toMatch(/connect-src[^;]*https:\/\/us\.i\.posthog\.com/);
expect(csp).not.toMatch(/script-src[^;]*posthog/);
// Hosted Clerk auth is opt-in at the client boundary; local CSP must not
// allow Clerk script or API hosts by default.
expect(csp).not.toMatch(/script-src[^;]*clerk/i);
expect(csp).not.toMatch(/connect-src[^;]*clerk/i);
expect(csp).toContain("img-src 'self' data: blob:");
expect(csp).toContain('https://fonts.googleapis.com');
});
it('includes rate limit headers on normal responses', async () => {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.headers['x-ratelimit-limit']).toBeDefined();
expect(res.headers['x-ratelimit-remaining']).toBeDefined();
});
});
// ── Rate Limiter (unit tests) ───────────────────────────────────────────
describe('RateLimiter', () => {
let limiter: RateLimiter;
afterEach(() => {
if (limiter) limiter.destroy();
});
it('allows requests within the limit', () => {
limiter = new RateLimiter({ maxRequests: 5, windowMs: 60_000 });
for (let i = 0; i < 5; i++) {
const result = limiter.check('test-key');
expect(result.allowed).toBe(true);
}
});
it('blocks requests after limit exceeded', () => {
limiter = new RateLimiter({ maxRequests: 3, windowMs: 60_000 });
limiter.check('test-key');
limiter.check('test-key');
limiter.check('test-key');
const result = limiter.check('test-key');
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.retryAfterMs).toBeGreaterThan(0);
}
});
it('tracks different keys independently', () => {
limiter = new RateLimiter({ maxRequests: 2, windowMs: 60_000 });
limiter.check('key-a');
limiter.check('key-a');
const resultA = limiter.check('key-a');
expect(resultA.allowed).toBe(false);
const resultB = limiter.check('key-b');
expect(resultB.allowed).toBe(true);
});
it('resets after window expires', async () => {
limiter = new RateLimiter({ maxRequests: 2, windowMs: 50 });
limiter.check('test-key');
limiter.check('test-key');
const blocked = limiter.check('test-key');
expect(blocked.allowed).toBe(false);
// Wait for window to expire
await new Promise(resolve => setTimeout(resolve, 80));
const afterReset = limiter.check('test-key');
expect(afterReset.allowed).toBe(true);
});
it('returns correct remaining count', () => {
limiter = new RateLimiter({ maxRequests: 5, windowMs: 60_000 });
const r1 = limiter.check('test-key');
expect(r1.allowed).toBe(true);
if (r1.allowed) expect(r1.remaining).toBe(4);
const r2 = limiter.check('test-key');
expect(r2.allowed).toBe(true);
if (r2.allowed) expect(r2.remaining).toBe(3);
});
});
// ── Rate Limiter (integration via Fastify) ──────────────────────────────
describe('Rate Limiter Integration', () => {
let server: ReturnType<typeof Fastify>;
beforeEach(async () => {
server = await createTestServer({ rateLimiter: { maxRequests: 3, windowMs: 60_000 } });
});
afterEach(async () => {
await server.close();
});
it('returns 429 after limit exceeded', async () => {
// Make 3 allowed requests
for (let i = 0; i < 3; i++) {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(200);
}
// 4th request should be rate-limited
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(429);
const body = res.json();
expect(body.error).toBe('Too Many Requests');
expect(body.retryAfterMs).toBeGreaterThan(0);
expect(res.headers['retry-after']).toBeDefined();
expect(res.headers['x-ratelimit-remaining']).toBe('0');
});
it('tracks different endpoints separately', async () => {
// Exhaust GET /api/test
for (let i = 0; i < 3; i++) {
await server.inject({ method: 'GET', url: '/api/test' });
}
const blocked = await server.inject({ method: 'GET', url: '/api/test' });
expect(blocked.statusCode).toBe(429);
// POST /api/test should still work (different key)
const postRes = await server.inject({ method: 'POST', url: '/api/test' });
expect(postRes.statusCode).toBe(200);
});
it('includes security headers even on 429 responses', async () => {
for (let i = 0; i < 3; i++) {
await server.inject({ method: 'GET', url: '/api/test' });
}
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(429);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-frame-options']).toBe('DENY');
});
});
// ── Per-Client Rate Limit Keying (CQ-008) ────────────────────────────────
describe('Per-Client Rate Limit Keying', () => {
it('different IPs get independent rate limit buckets', () => {
const limiter = new RateLimiter({ maxRequests: 2, windowMs: 60_000 });
// IP-A uses up its 2 requests
limiter.check('192.168.1.1:GET /api/test');
limiter.check('192.168.1.1:GET /api/test');
const blockedA = limiter.check('192.168.1.1:GET /api/test');
expect(blockedA.allowed).toBe(false);
// IP-B should still be allowed (independent bucket)
const allowedB = limiter.check('192.168.1.2:GET /api/test');
expect(allowedB.allowed).toBe(true);
limiter.destroy();
});
it('rate limit key includes client IP in integration test', async () => {
const server = await createTestServer({ rateLimiter: { maxRequests: 2, windowMs: 60_000 } });
try {
// Make 2 requests — should both succeed
for (let i = 0; i < 2; i++) {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(200);
}
// 3rd request should be blocked
const blocked = await server.inject({ method: 'GET', url: '/api/test' });
expect(blocked.statusCode).toBe(429);
// Simulate a different IP (inject uses remoteAddress — can't easily change,
// but the per-client key includes request.ip which defaults to 127.0.0.1 for inject)
// This test verifies the key format includes IP by checking the limiter's behavior
} finally {
await server.close();
}
});
});
// ── Per-Endpoint Rate Limits (CQ-008) ────────────────────────────────────
describe('Per-Endpoint Rate Limits', () => {
it('ENDPOINT_RATE_LIMITS has expected entries', () => {
expect(ENDPOINT_RATE_LIMITS['/api/chat']).toBe(120);
expect(ENDPOINT_RATE_LIMITS['/api/vault/*/reveal']).toBe(5);
expect(ENDPOINT_RATE_LIMITS['/api/backup']).toBe(2);
expect(ENDPOINT_RATE_LIMITS['/api/restore']).toBe(2);
});
it('getEffectiveLimit returns per-endpoint limits for expensive routes', () => {
const limiter = new RateLimiter();
expect(limiter.getEffectiveLimit('/api/chat')).toBe(120);
expect(limiter.getEffectiveLimit('/api/vault/MY_SECRET/reveal')).toBe(5);
expect(limiter.getEffectiveLimit('/api/backup')).toBe(2);
expect(limiter.getEffectiveLimit('/api/restore')).toBe(2);
expect(limiter.getEffectiveLimit('/api/test')).toBe(100); // default
expect(limiter.getEffectiveLimit('/api/workspaces')).toBe(100); // default
limiter.destroy();
});
it('check() uses custom maxRequests override', () => {
const limiter = new RateLimiter({ maxRequests: 100, windowMs: 60_000 });
// With override of 2, should block on 3rd request
limiter.check('key', 2);
limiter.check('key', 2);
const blocked = limiter.check('key', 2);
expect(blocked.allowed).toBe(false);
limiter.destroy();
});
it('expensive endpoints return their limit in X-RateLimit-Limit header', async () => {
const server = await createTestServer({ rateLimiter: { maxRequests: 100, windowMs: 60_000 } });
try {
const chatRes = await server.inject({ method: 'POST', url: '/api/chat' });
expect(chatRes.headers['x-ratelimit-limit']).toBe('120');
const backupRes = await server.inject({ method: 'POST', url: '/api/backup' });
expect(backupRes.headers['x-ratelimit-limit']).toBe('2');
const vaultRes = await server.inject({ method: 'POST', url: '/api/vault/MY_SECRET/reveal' });
expect(vaultRes.headers['x-ratelimit-limit']).toBe('5');
const normalRes = await server.inject({ method: 'GET', url: '/api/test' });
expect(normalRes.headers['x-ratelimit-limit']).toBe('100');
} finally {
await server.close();
}
});
it('backup endpoint blocks after 2 requests', async () => {
const server = await createTestServer({ rateLimiter: { maxRequests: 100, windowMs: 60_000 } });
try {
// 2 allowed
for (let i = 0; i < 2; i++) {
const res = await server.inject({ method: 'POST', url: '/api/backup' });
expect(res.statusCode).toBe(200);
}
// 3rd blocked
const blocked = await server.inject({ method: 'POST', url: '/api/backup' });
expect(blocked.statusCode).toBe(429);
// But /api/test should still work (different endpoint)
const testRes = await server.inject({ method: 'GET', url: '/api/test' });
expect(testRes.statusCode).toBe(200);
} finally {
await server.close();
}
});
});
// ── Bearer Token Authentication (SEC-011) ────────────────────────────────
describe('Bearer Token Authentication', () => {
const TEST_TOKEN = 'test-session-token-12345';
// The global test setup defaults to WAGGLE_TRUST_LOCALHOST=1 so the broad suite
// (raw inject, no tokens) keeps working. This describe exercises the SECURE D1
// default, so force trust OFF here and restore the suite default afterward.
beforeEach(() => { process.env.WAGGLE_TRUST_LOCALHOST = '0'; });
afterEach(() => { process.env.WAGGLE_TRUST_LOCALHOST = '1'; });
it('D1: requires a token on localhost (no desktop trust by default)', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'GET', url: '/api/test' });
// D1: localhost is no longer auto-trusted — a missing token is 401.
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('MISSING_TOKEN');
} finally {
await server.close();
}
});
it('D1: rejects a wrong token on localhost', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({
method: 'GET',
url: '/api/test',
headers: { authorization: 'Bearer wrong-token' },
});
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('INVALID_TOKEN');
} finally {
await server.close();
}
});
it('allows request with valid token', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({
method: 'GET',
url: '/api/test',
headers: { authorization: `Bearer ${TEST_TOKEN}` },
});
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
} finally {
await server.close();
}
});
it('accepts a narrow run token only on WaggleDance transport routes', async () => {
const runToken = 'run-token-with-enough-entropy-1234567890';
const server = await createTestServer({
sessionToken: TEST_TOKEN,
authenticateRunToken: (candidate) => candidate === runToken,
});
try {
const send = await server.inject({
method: 'POST', url: '/api/waggle-dance/signal',
headers: { 'x-waggle-run-token': runToken },
});
expect(send.statusCode).toBe(200);
const receive = await server.inject({
method: 'GET', url: '/api/waggle-dance/signals',
headers: { 'x-waggle-run-token': runToken },
});
expect(receive.statusCode).toBe(200);
const unrelated = await server.inject({
method: 'GET', url: '/api/test',
headers: { 'x-waggle-run-token': runToken },
});
expect(unrelated.statusCode).toBe(401);
const wrong = await server.inject({
method: 'POST', url: '/api/waggle-dance/signal',
headers: { 'x-waggle-run-token': 'wrong-run-token-with-enough-entropy-123' },
});
expect(wrong.statusCode).toBe(401);
expect(wrong.json().code).toBe('INVALID_TOKEN');
} finally {
await server.close();
}
});
it('health endpoint works without token', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json().wsToken).toBe(TEST_TOKEN);
} finally {
await server.close();
}
});
it('OPTIONS requests bypass auth (CORS preflight)', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'OPTIONS', url: '/api/test' });
// OPTIONS may return 404 (no handler) but NOT 401
expect(res.statusCode).not.toBe(401);
} finally {
await server.close();
}
});
it('does not require auth when sessionToken is not configured', async () => {
const server = await createTestServer(); // no sessionToken
try {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(200);
} finally {
await server.close();
}
});
// ── D1 bootstrap fix: non-API GETs (SPA shell + static assets) must be
// auth-exempt. Otherwise a browser/webview gets 401 on GET / and can never
// load the app code that fetches the bearer token (unbootstrappable). The
// /api/auth/session-token endpoint is same-origin gated; privileged actions
// all live under /api/* and stay gated below. ──────────────────────────
it('D1 bootstrap: serves the SPA shell (GET /) WITHOUT a token', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'GET', url: '/' });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('waggle');
} finally {
await server.close();
}
});
it('D1 bootstrap: serves a static asset (GET /assets/*) WITHOUT a token', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'GET', url: '/assets/app.js' });
expect(res.statusCode).toBe(200);
} finally {
await server.close();
}
});
it('D1: a NON-GET to a non-/api path still requires a token (exemption is GET-only)', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
// POST / is not a static-asset read; the GET-only exemption must not cover it.
const res = await server.inject({ method: 'POST', url: '/' });
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('MISSING_TOKEN');
} finally {
await server.close();
}
});
it('D1: /api/* GETs are STILL gated (exemption does not leak to the API)', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('MISSING_TOKEN');
} finally {
await server.close();
}
});
// ── P1b-SSE: EventSource cannot send headers, so the four SSE stream paths
// accept ?token= (the /ws pattern). Scope: GET-only, allowlist-only,
// header-absent-only. ────────────────────────────────────────────────────
describe('SSE query-token auth (P1b-SSE)', () => {
const SSE_PATHS = [
'/api/notifications/stream',
'/api/events/stream',
'/api/waggle/stream',
'/api/harvest/progress',
];
async function createSseTestServer() {
// createTestServer is already .ready() — build a fresh instance so the
// SSE routes can register before the listener locks.
const server = Fastify({ logger: false });
await server.register(securityMiddleware, { sessionToken: TEST_TOKEN });
server.get('/api/test', async () => ({ ok: true }));
for (const p of SSE_PATHS) {
server.get(p, async () => ({ ok: true, stream: p }));
}
await server.ready();
return server;
}
it.each(SSE_PATHS)('%s authenticates via ?token= (no header)', async (path) => {
const server = await createSseTestServer();
try {
const res = await server.inject({ method: 'GET', url: `${path}?token=${TEST_TOKEN}` });
expect(res.statusCode).toBe(200);
} finally {
await server.close();
}
});
it('rejects a WRONG query token with INVALID_TOKEN', async () => {
const server = await createSseTestServer();
try {
const res = await server.inject({ method: 'GET', url: `/api/notifications/stream?token=wrong` });
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('INVALID_TOKEN');
} finally {
await server.close();
}
});
it('rejects a MISSING query token with MISSING_TOKEN', async () => {
const server = await createSseTestServer();
try {
const res = await server.inject({ method: 'GET', url: '/api/notifications/stream' });
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('MISSING_TOKEN');
} finally {
await server.close();
}
});
it('does NOT leak query-token auth to non-allowlisted /api GETs', async () => {
const server = await createSseTestServer();
try {
const res = await server.inject({ method: 'GET', url: `/api/test?token=${TEST_TOKEN}` });
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('MISSING_TOKEN');
} finally {
await server.close();
}
});
it('an Authorization header always wins over the query token', async () => {
const server = await createSseTestServer();
try {
// Valid query token + INVALID header → the header is authoritative → 401.
const res = await server.inject({
method: 'GET',
url: `/api/notifications/stream?token=${TEST_TOKEN}`,
headers: { authorization: 'Bearer wrong-token' },
});
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('INVALID_TOKEN');
} finally {
await server.close();
}
});
it('a valid header still works on SSE paths (back-compat for header-capable clients)', async () => {
const server = await createSseTestServer();
try {
const res = await server.inject({
method: 'GET',
url: '/api/events/stream',
headers: { authorization: `Bearer ${TEST_TOKEN}` },
});
expect(res.statusCode).toBe(200);
} finally {
await server.close();
}
});
});
it('D1: rejects a malformed authorization header (no Bearer prefix)', async () => {
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({
method: 'GET',
url: '/api/test',
headers: { authorization: TEST_TOKEN }, // missing "Bearer " prefix → token parses null
});
expect(res.statusCode).toBe(401);
expect(res.json().code).toBe('INVALID_TOKEN');
} finally {
await server.close();
}
});
it('D1 escape hatch: WAGGLE_TRUST_LOCALHOST=1 restores legacy loopback trust', async () => {
process.env.WAGGLE_TRUST_LOCALHOST = '1';
const server = await createTestServer({ sessionToken: TEST_TOKEN });
try {
const res = await server.inject({ method: 'GET', url: '/api/test' });
expect(res.statusCode).toBe(200);
} finally {
delete process.env.WAGGLE_TRUST_LOCALHOST;
await server.close();
}
});
});
// ── Vault Reveal Origin Enforcement ─────────────────────────────────────
describe('Vault Reveal Origin Enforcement', () => {
it('blocks requests with external origin header', async () => {
// This tests the vault route directly — import and set up a minimal server
const { vaultRoutes } = await import('../../src/local/routes/vault.js');
const { VaultStore } = await import('@waggle/core');
const path = await import('node:path');
const os = await import('node:os');
const fs = await import('node:fs');
const tmpDir = path.join(os.tmpdir(), `waggle-vault-origin-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const vault = new VaultStore(tmpDir);
vault.set('MY_SECRET', 'hidden-value', { credentialType: 'api_key' });
const server = Fastify({ logger: false });
server.decorate('vault', vault);
server.register(vaultRoutes);
try {
// Allowed: no origin header (local call)
const allowedRes = await server.inject({
method: 'POST',
url: '/api/vault/MY_SECRET/reveal',
});
expect(allowedRes.statusCode).toBe(200);
expect(allowedRes.json().value).toBe('hidden-value');
// Allowed: localhost origin
const localRes = await server.inject({
method: 'POST',
url: '/api/vault/MY_SECRET/reveal',
headers: { origin: 'http://127.0.0.1:1420' },
});
expect(localRes.statusCode).toBe(200);
// Allowed: tauri origin
const tauriRes = await server.inject({
method: 'POST',
url: '/api/vault/MY_SECRET/reveal',
headers: { origin: 'tauri://localhost' },
});
expect(tauriRes.statusCode).toBe(200);
// Blocked: external origin
const blockedRes = await server.inject({
method: 'POST',
url: '/api/vault/MY_SECRET/reveal',
headers: { origin: 'https://evil.example.com' },
});
expect(blockedRes.statusCode).toBe(403);
expect(blockedRes.json().error).toContain('external origin');
} finally {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});