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:
parent
d5c95ace0a
commit
29c96a207e
14 changed files with 2454 additions and 3 deletions
127
tests/server/middleware/csrf.test.js
Normal file
127
tests/server/middleware/csrf.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
95
tests/server/middleware/rate-limit.test.js
Normal file
95
tests/server/middleware/rate-limit.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
106
tests/server/middleware/security-headers.test.js
Normal file
106
tests/server/middleware/security-headers.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue