/** * 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 'waggle'; }); server.get('/assets/app.js', async () => { return 'console.log("app");'; }); await server.ready(); return server; } // ── Security Headers ──────────────────────────────────────────────────── describe('Security Headers', () => { let server: ReturnType; 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; 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 }); } }); });