Add Vitest security test suite and update security evaluation doc

Set up Vitest with server (node) and client (jsdom) test projects.
79 tests across 8 files verify all Phase 0-1 security controls:
escapeHtml sanitization, DOMPurify markdown XSS prevention, CSRF
enforcement, security headers, rate limiting, auth guards, profile
field allowlist, and login anti-enumeration. Updated SECURITY_EVALUATION.md
with remediation status, implementation summary, and automated test
coverage details.
This commit is contained in:
Jennie Robinson Faber 2026-03-01 12:30:06 +00:00
parent d5c95ace0a
commit 29c96a207e
14 changed files with 2454 additions and 3 deletions

View file

@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
// Import the handler — defineEventHandler is a global passthrough,
// so the default export is the raw handler function.
import csrfMiddleware from '../../../server/middleware/01.csrf.js'
describe('CSRF middleware', () => {
describe('safe methods bypass', () => {
it.each(['GET', 'HEAD', 'OPTIONS'])('%s requests pass through', (method) => {
const event = createMockEvent({ method, path: '/api/test' })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('non-API paths bypass', () => {
it('POST to non-/api/ path passes through', () => {
const event = createMockEvent({ method: 'POST', path: '/some/page' })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('exempt routes bypass', () => {
it.each([
'/api/helcim/webhook',
'/api/slack/webhook',
'/api/auth/verify'
])('POST to %s passes through', (path) => {
const event = createMockEvent({ method: 'POST', path })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('CSRF enforcement on state-changing API requests', () => {
it('throws 403 when POST to /api/* has no x-csrf-token header', () => {
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': 'abc123' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
)
})
it('throws 403 when header and cookie tokens do not match', () => {
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': 'cookie-token' },
headers: { 'x-csrf-token': 'different-token' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
)
})
it('passes when header and cookie tokens match', () => {
const token = 'matching-token-value'
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': token },
headers: { 'x-csrf-token': token }
})
expect(() => csrfMiddleware(event)).not.toThrow()
})
it('enforces CSRF on DELETE requests', () => {
const event = createMockEvent({
method: 'DELETE',
path: '/api/admin/events/123',
cookies: { 'csrf-token': 'abc' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({ statusCode: 403 })
)
})
it('enforces CSRF on PATCH requests', () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
cookies: { 'csrf-token': 'abc' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({ statusCode: 403 })
)
})
})
describe('cookie provisioning', () => {
it('sets csrf-token cookie when none exists', () => {
const event = createMockEvent({ method: 'GET', path: '/api/test' })
csrfMiddleware(event)
// The middleware calls setCookie which sets a Set-Cookie header
const setCookieHeader = event._testSetHeaders['set-cookie']
expect(setCookieHeader).toBeDefined()
expect(setCookieHeader).toContain('csrf-token=')
})
it('does not set a new cookie when one already exists', () => {
const event = createMockEvent({
method: 'GET',
path: '/api/test',
cookies: { 'csrf-token': 'existing-token' }
})
csrfMiddleware(event)
const setCookieHeader = event._testSetHeaders['set-cookie']
// Should not have set a new cookie
expect(setCookieHeader).toBeUndefined()
})
})
})

View file

@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('rate-limit middleware', () => {
// Fresh import per describe block to get fresh limiter instances
let rateLimitMiddleware
beforeEach(async () => {
vi.resetModules()
const mod = await import('../../../server/middleware/03.rate-limit.js')
rateLimitMiddleware = mod.default
})
describe('non-API paths', () => {
it('skips rate limiting for non-/api/ paths', async () => {
const event = createMockEvent({ path: '/about', remoteAddress: '10.0.0.1' })
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
})
})
describe('auth endpoint limiting (5 per 5 min)', () => {
it('allows 5 requests then blocks the 6th', async () => {
const ip = '10.0.1.1'
// First 5 should succeed
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
// 6th should be rate limited
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
// Check Retry-After header was set
expect(event._testSetHeaders['retry-after']).toBeDefined()
})
})
describe('payment endpoint limiting (10 per min)', () => {
it('allows 10 requests then blocks the 11th', async () => {
const ip = '10.0.2.1'
for (let i = 0; i < 10; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
})
})
describe('IP isolation', () => {
it('different IPs have separate rate limit counters', async () => {
// Exhaust limit for IP A
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: '10.0.3.1'
})
await rateLimitMiddleware(event)
}
// IP B should still be able to make requests
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: '10.0.3.2'
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
})
})
})

View file

@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
import securityHeadersMiddleware from '../../../server/middleware/02.security-headers.js'
describe('security-headers middleware', () => {
const originalNodeEnv = process.env.NODE_ENV
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
describe('always-present headers', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
})
it('sets X-Content-Type-Options to nosniff', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-content-type-options']).toBe('nosniff')
})
it('sets X-Frame-Options to DENY', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-frame-options']).toBe('DENY')
})
it('sets X-XSS-Protection to 0', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-xss-protection']).toBe('0')
})
it('sets Referrer-Policy', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['referrer-policy']).toBe('strict-origin-when-cross-origin')
})
it('sets Permissions-Policy', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['permissions-policy']).toBe('camera=(), microphone=(), geolocation=()')
})
})
describe('production-only headers', () => {
it('sets HSTS in production', () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['strict-transport-security']).toBe('max-age=31536000; includeSubDomains')
})
it('does not set HSTS in development', () => {
process.env.NODE_ENV = 'development'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['strict-transport-security']).toBeUndefined()
})
it('sets CSP in production', () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['content-security-policy']).toBeDefined()
})
it('does not set CSP in development', () => {
process.env.NODE_ENV = 'development'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['content-security-policy']).toBeUndefined()
})
})
describe('CSP directives', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production'
})
it('includes Helcim sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('myposjs.helcim.com')
expect(csp).toContain('api.helcim.com')
expect(csp).toContain('secure.helcim.com')
})
it('includes Cloudinary sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('res.cloudinary.com')
})
it('includes Plausible sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('plausible.io')
})
})
})