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

282
docs/SECURITY_EVALUATION.md Normal file
View file

@ -0,0 +1,282 @@
# Ghost Guild Security Evaluation
**Date:** 2026-02-28 (updated 2026-03-01)
**Framework:** OWASP ASVS v4.0, Level 1
**Scope:** Full application stack (Nuxt 4 + Nitro server + MongoDB)
---
## Framework Rationale
Ghost Guild handles payments (Helcim), PII, and user-generated content. We evaluated four frameworks before selecting ASVS:
| Framework | Why Not |
|---|---|
| PCI-DSS | Too narrow -- only covers payment surface, misses auth/XSS/access control |
| NIST CSF / ISO 27001 | Organizational frameworks, not application-level |
| OWASP Top 10 | Too coarse -- categories without testable requirements |
| **OWASP ASVS L1** | **Selected** -- purpose-built for web apps, testable pass/fail criteria across 14 chapters |
ASVS Level 1 targets "all software" and is achievable for a small team.
---
## Findings
### Severity Summary
| Severity | Count | Key Areas |
|----------|-------|-----------|
| CRITICAL | 5 | Admin auth disabled, payment verification stub, XSS (markdown + emails), cookie flags |
| HIGH | 9 | No rate limiting, no CSRF, JWT secret fallback, unauthenticated endpoints, mass assignment |
| MEDIUM | 5 | Long-lived tokens, Slack secrets unused, devtools, data logging, regex injection |
| LOW | 2 | User enumeration, email format validation |
---
### CRITICAL
#### C1. Admin endpoints completely unprotected
- **ASVS:** V4.1.1 (Trusted enforcement)
- **Files:** All routes under `server/api/admin/`
- **Evidence:** Auth code is commented out with `// TODO: Temporarily disabled auth for testing`. Anyone can list all members, create/delete events, and view revenue dashboard. The Member model has no `role` or `isAdmin` field.
#### C2. Payment verification is a stub
- **ASVS:** V10.2.1 (Business logic integrity)
- **File:** `server/api/helcim/verify-payment.post.js`
- **Evidence:** Returns `{ success: true, cardToken: body.cardToken }` without calling the Helcim API.
#### C3. XSS via unsanitized markdown
- **ASVS:** V5.3.3 (Output encoding for HTML)
- **Files:** `app/composables/useMarkdown.js`, `app/pages/members.vue:247`
- **Evidence:** `marked()` output rendered via `v-html` with no DOMPurify.
#### C4. XSS in email templates
- **ASVS:** V5.2.6 (Server-side injection)
- **File:** `server/utils/resend.js` (11+ interpolation points)
- **Evidence:** User-supplied values interpolated directly into HTML email bodies.
#### C5. Session cookie allows JavaScript access
- **ASVS:** V3.2.1 (Cookie attributes)
- **File:** `server/api/auth/verify.get.js:40-45`
- **Evidence:** `httpOnly: false, secure: false`. Session token accessible to any JavaScript.
---
### HIGH
#### H1. No rate limiting on any endpoint
- **ASVS:** V13.2.5
- **Evidence:** No rate limiting middleware anywhere. Login, registration, uploads, and payment endpoints are unlimited.
#### H2. No CSRF protection
- **ASVS:** V3.5.2
- **Evidence:** No CSRF tokens, double-submit cookies, or CSRF middleware.
#### H3. Hardcoded JWT fallback secret
- **ASVS:** V6.4.1 (Key management)
- **File:** `nuxt.config.ts:17`
- **Evidence:** `jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production'`
#### H4. File upload endpoint unauthenticated
- **ASVS:** V4.1.3
- **File:** `server/api/upload/image.post.js`
- **Evidence:** No auth check. Anyone can upload images.
#### H5. Helcim payment endpoints mostly unauthenticated
- **ASVS:** V4.1.1
- **Files:** `verify-payment.post.js`, `initialize-payment.post.js`, `update-billing.post.js`, `subscription.post.js`
#### H6. Mass assignment: helcimCustomerId in allowedFields
- **ASVS:** V5.1.3
- **File:** `server/api/members/profile.patch.js:40`
- **Evidence:** A member can change their own Helcim customer ID.
#### H7. No input validation
- **ASVS:** V5.1.3
- **Evidence:** Zod is installed but has zero imports. All validation is ad-hoc.
#### H8. User enumeration on login
- **ASVS:** V2.1.7
- **File:** `server/api/auth/login.post.js:24-28`
- **Evidence:** Returns 404 "No account found" vs success.
#### H9. No security headers
- **ASVS:** V9.1.1
- **Evidence:** No CSP, HSTS, X-Frame-Options, or X-Content-Type-Options.
---
### MEDIUM
| ID | ASVS | Finding | File |
|----|------|---------|------|
| M1 | V2.5.2 | 30-day static session token, no rotation | `verify.get.js:36` |
| M2 | V10.2.2 | Slack signing secret defined but never used | `nuxt.config.ts:21` |
| M3 | V7.4.1 | DevTools enabled unconditionally | `nuxt.config.ts:4` |
| M4 | V7.1.1 | Console logging of payment data and API token substrings | `helcim/customer.post.js:42` |
| M5 | V5.3.4 | Unescaped regex in directory search | `members/directory.get.js:49-51` |
### LOW
| ID | ASVS | Finding | File |
|----|------|---------|------|
| L1 | V2.1.7 | Timing-based enumeration via DB lookup | `auth/login.post.js` |
| L2 | V5.1.3 | No email format validation on login | `auth/login.post.js:15` |
---
## Future Feature Risk Assessment
| Planned Feature | Risk | Must Address First |
|---|---|---|
| Rich text member updates | Same XSS pattern as C3 | Fix markdown sanitization (C3) |
| Resource library with downloads | Unauthenticated upload (H4), malware distribution | Add upload auth (H4), file validation |
| Etherpad integration | External content rendered unsanitized | Build sanitization utility (C3) |
| Cal.com integration | API credential exposure | Fix secret management (H3) |
| Member-proposed events | No admin role model, no approval workflow | Build RBAC (C1) |
| Advanced search/analytics | Regex injection (M5), privacy leakage | Fix regex escaping (M5) |
---
## Remediation Summary (Phases 0-1 + partial Phase 2)
All work lives on branch `security/asvs-remediation`.
### Auth guards (`server/utils/auth.js`)
- `requireAuth(event)` -- Reads JWT from `auth-token` cookie, verifies against `jwtSecret`, loads member from DB, rejects suspended/cancelled accounts (403). Auto-imported by Nitro.
- `requireAdmin(event)` -- Calls `requireAuth`, then checks `member.role === 'admin'` (403 if not). Member model gained `role` field (enum: `member`/`admin`, default `member`).
- Applied to all `server/api/admin/`, `server/api/upload/`, and `server/api/helcim/` endpoints.
### CSRF (`server/middleware/01.csrf.js` + `app/plugins/csrf.client.js`)
- Double-submit cookie pattern. Middleware generates a random token cookie on first request, then enforces that POST/PATCH/DELETE to `/api/*` include a matching `x-csrf-token` header.
- Client plugin reads the cookie and attaches the header on every `$fetch` request.
- Exempt routes: `/api/helcim/webhook`, `/api/slack/webhook`, `/api/auth/verify`.
### Security headers (`server/middleware/02.security-headers.js`)
- Always: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
- Production only: HSTS (1 year, includeSubDomains) and CSP allowing Helcim, Cloudinary, and Plausible sources.
### Rate limiting (`server/middleware/03.rate-limit.js`)
- `rate-limiter-flexible` with in-memory stores, keyed by client IP.
- Auth endpoints: 5 requests per 5 minutes. Payment endpoints: 10 per minute. Uploads: 10 per minute. General API: 100 per minute.
- Returns 429 with `Retry-After` header on exhaustion.
### XSS prevention
- **Markdown** (`app/composables/useMarkdown.js`): `marked()` output sanitized through DOMPurify with explicit allowlists for tags and attributes. Strips script/iframe/object/embed/img and all event handler attributes.
- **Email templates** (`server/utils/escapeHtml.js`): Pure function escaping `& < > " '` to HTML entities. Applied to all user-supplied interpolations in `server/utils/resend.js`.
### Anti-enumeration (`server/api/auth/login.post.js`)
- Login returns identical `{ success: true, message: "If this email is registered, we've sent a login link." }` for both existing and non-existing emails.
### Mass assignment (`server/api/members/profile.patch.js`)
- Explicit allowlist of profile fields. `helcimCustomerId`, `role`, `status`, `email`, `_id` are excluded from the `$set` update.
### Session management
- 7-day token expiry with refresh endpoint at `/api/auth/refresh`.
---
## Remediation Roadmap
### Phase 0: Emergency (before any production traffic) -- COMPLETE
| # | Finding | Fix | ASVS | Status |
|---|---------|-----|------|--------|
| 1 | C5 | Set `httpOnly: true`, `secure` conditional on NODE_ENV | V3.2.1 | Done |
| 2 | C3 | Install `isomorphic-dompurify`, sanitize `marked()` output | V5.3.3 | Done |
| 3 | C1 | Add `role` field to Member model, re-enable admin auth with role check | V4.1.1 | Done |
| 4 | C2 | Call Helcim API in verify-payment to confirm transactions server-side | V10.2.1 | Done |
| 5 | H3 | Throw on missing JWT_SECRET instead of fallback | V6.4.1 | Done |
### Phase 1: Pre-launch (before public access) -- COMPLETE
| # | Finding | Fix | ASVS | Status |
|---|---------|-----|------|--------|
| 6 | C4 | Create `escapeHtml()` utility, apply to all email template interpolations | V5.2.6 | Done |
| 7 | H4 | Add JWT verification to upload endpoint | V4.1.3 | Done |
| 8 | H5 | Add JWT verification to payment endpoints | V4.1.1 | Done |
| 9 | H6 | Remove `helcimCustomerId` from `allowedFields` | V5.1.3 | Done |
| 10 | H2 | Add CSRF double-submit cookie middleware (exempt webhooks) | V3.5.2 | Done |
| 11 | H9 | Add CSP, HSTS, X-Frame-Options, X-Content-Type-Options headers | V9.1.1 | Done |
| 12 | H1 | Add rate limiting to login, registration, upload, payment | V13.2.5 | Done |
| 13 | H8 | Return identical response for existing/non-existing accounts | V2.1.7 | Done |
| 14 | -- | Add `status: 'active'` check to auth endpoints | V4.1.1 | Done |
### Phase 2: Hardening (within 30 days of launch) -- PARTIAL
| # | Finding | Fix | ASVS | Status |
|---|---------|-----|------|--------|
| 15 | H7 | Implement Zod validation across all API endpoints | V5.1.3 | Open |
| 16 | M5 | Escape regex in directory search | V5.3.4 | Open |
| 17 | M4 | Remove sensitive console.log statements | V7.1.1 | Open |
| 18 | M3 | Make devtools conditional on NODE_ENV | V7.4.1 | Open |
| 19 | M1 | Shorter session tokens (7d) with refresh endpoint | V2.5.2 | Done |
| 20 | -- | Create shared `requireAuth()`/`requireAdmin()` utilities | V4.1.1 | Done |
### Phase 3: Before building planned features
| # | Fix | Status |
|---|-----|--------|
| 21 | Build sanitization utility (DOMPurify wrapper) for all user-generated HTML | Open |
| 22 | Design admin role model with granular permissions | Open |
| 23 | Implement file validation pipeline (type, size, virus scanning) | Open |
| 24 | Design credential management patterns (encrypted at rest) | Open |
---
## Automated Testing
### Framework
Vitest with two test projects:
- **Server tests** (`tests/server/`): Node.js environment, h3 globals stubbed from real h3 functions
- **Client tests** (`tests/client/`): jsdom environment for browser-side composables
```bash
npm run test # Watch mode
npm run test:run # Single run (CI)
```
### Infrastructure
- `tests/server/setup.js` -- Stubs real h3 functions (`getCookie`, `setCookie`, `getMethod`, `getHeader`, `setHeader`, `getRequestURL`, `createError`, `defineEventHandler`, `readBody`, etc.) as globals to simulate Nitro auto-imports. Also stubs `useRuntimeConfig`.
- `tests/server/helpers/createMockEvent.js` -- Factory that builds real h3 events from Node.js `IncomingMessage`/`ServerResponse` pairs. Accepts `method`, `path`, `headers`, `cookies`, `body`, and `remoteAddress`. Captures response headers via `event._testSetHeaders` for assertions.
### Test Coverage (79 tests across 8 files)
| File | Tests | Security Controls Verified |
|------|-------|---------------------------|
| `tests/server/utils/escapeHtml.test.js` | 12 | All 5 HTML entity escapes, null/undefined handling, `<script>` and `<img onerror>` XSS payloads (C4, V5.2.6) |
| `tests/client/composables/useMarkdown.test.js` | 18 | Script/iframe/object/embed tags stripped, onerror/onclick attrs stripped, javascript: URIs sanitized, safe markdown preserved (C3, V5.3.3) |
| `tests/server/middleware/csrf.test.js` | 14 | GET/HEAD/OPTIONS bypass, non-API bypass, webhook exemptions, 403 on missing/mismatched token, PATCH/DELETE enforcement, cookie provisioning (H2, V3.5.2) |
| `tests/server/middleware/security-headers.test.js` | 12 | X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy always set; HSTS + CSP production-only; CSP includes Helcim/Cloudinary/Plausible sources (H9, V9.1.1) |
| `tests/server/middleware/rate-limit.test.js` | 4 | Auth endpoint 5/5min limit, payment endpoint 10/min limit, IP isolation between clients (H1, V13.2.5) |
| `tests/server/utils/auth.test.js` | 8 | No cookie = 401, invalid JWT = 401, member not found = 401, suspended = 403, cancelled = 403, active = returns member, non-admin = 403, admin = returns member (C1, V4.1.1) |
| `tests/server/api/auth-login.test.js` | 4 | Existing and non-existing emails return identical response shape and message, missing email = 400 (H8, V2.1.7) |
| `tests/server/api/members-profile-patch.test.js` | 7 | `helcimCustomerId`, `role`, `status`, `email`, `_id` blocked from `$set`; allowed fields (`pronouns`, `bio`, `studio`, etc.) and nested objects (`offering`, `lookingFor`) pass through (H6, V5.1.3) |
### Manual Test Cases
These items require browser or network-level verification and are not covered by automated tests:
**Item 1 (Cookie flags):** Open DevTools > Application > Cookies. Verify `auth-token` has `HttpOnly: true`. Run `document.cookie` in console -- `auth-token` must NOT appear. Auth flow must still work.
**Item 4 (Payment verification):** Fake `cardToken` = failure. Real HelcimPay.js flow in test mode = success.
**Item 5 (JWT fallback):** Unset `JWT_SECRET`, start server = crash with clear error. Set it = normal startup.
**Item 7 (Upload auth):** POST `/api/upload/image` with no cookie = 401. With valid auth = success.
**Item 8 (Payment auth):** Each endpoint with no cookie = 401. Full join flow still completes.
### Integration verification (after each phase)
- `npm run test:run` -- all 79 tests pass
- `npm run build` succeeds
- Full join flow: free tier + paid tier
- Full login flow: magic link request, click, redirect to `/members`
- Profile editing: avatar upload, bio update, privacy settings
- Admin pages: access control verified
- `curl` against hardened endpoints: unauthenticated = rejected

1123
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@cloudinary/vue": "^1.13.3", "@cloudinary/vue": "^1.13.3",
@ -21,11 +23,13 @@
"chrono-node": "^2.8.4", "chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"isomorphic-dompurify": "^3.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"marked": "^17.0.3", "marked": "^17.0.3",
"mongoose": "^8.18.0", "mongoose": "^8.18.0",
"nitro-cors": "^0.7.1", "nitro-cors": "^0.7.1",
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"rate-limiter-flexible": "^9.1.1",
"resend": "^6.0.1", "resend": "^6.0.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vue": "^3.5.20", "vue": "^3.5.20",
@ -33,6 +37,9 @@
"zod": "^4.1.3" "zod": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19" "@nuxt/test-utils": "^4.0.0",
"@tailwindcss/typography": "^0.5.19",
"jsdom": "^28.1.0",
"vitest": "^4.0.18"
} }
} }

View file

@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { useMarkdown } from '../../../app/composables/useMarkdown.js'
describe('useMarkdown', () => {
const { render } = useMarkdown()
describe('XSS prevention', () => {
it('strips script tags', () => {
const result = render('Hello <script>alert("xss")</script> world')
expect(result).not.toContain('<script>')
expect(result).not.toContain('</script>')
expect(result).toContain('Hello')
expect(result).toContain('world')
})
it('strips onerror attributes', () => {
const result = render('<img onerror="alert(1)" src="x">')
expect(result).not.toContain('onerror')
})
it('strips onclick attributes', () => {
const result = render('<a onclick="alert(1)" href="#">click</a>')
expect(result).not.toContain('onclick')
})
it('strips iframe tags', () => {
const result = render('<iframe src="https://evil.com"></iframe>')
expect(result).not.toContain('<iframe')
})
it('strips object tags', () => {
const result = render('<object data="exploit.swf"></object>')
expect(result).not.toContain('<object')
})
it('strips embed tags', () => {
const result = render('<embed src="exploit.swf">')
expect(result).not.toContain('<embed')
})
it('sanitizes javascript: URIs', () => {
const result = render('[click me](javascript:alert(1))')
expect(result).not.toContain('javascript:')
})
it('strips img tags (not in allowed list)', () => {
const result = render('![alt](https://example.com/img.png)')
expect(result).not.toContain('<img')
})
})
describe('preserves safe markdown', () => {
it('renders bold and italic', () => {
const result = render('**bold** and *italic*')
expect(result).toContain('<strong>bold</strong>')
expect(result).toContain('<em>italic</em>')
})
it('renders links with href', () => {
const result = render('[Ghost Guild](https://ghostguild.org)')
expect(result).toContain('<a')
expect(result).toContain('href="https://ghostguild.org"')
})
it('preserves headings h1-h6', () => {
for (let i = 1; i <= 6; i++) {
const hashes = '#'.repeat(i)
const result = render(`${hashes} Heading ${i}`)
expect(result).toContain(`<h${i}>`)
}
})
it('preserves code blocks', () => {
const result = render('`inline code` and\n\n```\nblock code\n```')
expect(result).toContain('<code>')
expect(result).toContain('<pre>')
})
it('preserves blockquotes', () => {
const result = render('> This is a quote')
expect(result).toContain('<blockquote>')
})
it('preserves lists', () => {
const result = render('- item 1\n- item 2')
expect(result).toContain('<ul>')
expect(result).toContain('<li>')
})
it('preserves allowed attributes: href, target, rel, class', () => {
// DOMPurify allows href on <a> tags
const result = render('[link](https://example.com)')
expect(result).toContain('href=')
})
})
describe('edge cases', () => {
it('returns empty string for null', () => {
expect(render(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(render(undefined)).toBe('')
})
it('returns empty string for empty string', () => {
expect(render('')).toBe('')
})
})
})

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn().mockReturnValue('mock-jwt-token') }
}))
vi.mock('resend', () => ({
Resend: class MockResend {
constructor() {
this.emails = { send: vi.fn().mockResolvedValue({ id: 'email-123' }) }
}
}
}))
import Member from '../../../server/models/member.js'
import loginHandler from '../../../server/api/auth/login.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('auth login endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns generic success message for existing member', async () => {
Member.findOne.mockResolvedValue({
_id: 'member-123',
email: 'exists@example.com'
})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'exists@example.com' },
headers: { host: 'localhost:3000' }
})
const result = await loginHandler(event)
expect(result).toEqual({
success: true,
message: "If this email is registered, we've sent a login link."
})
})
it('returns identical response for non-existing member (anti-enumeration)', async () => {
Member.findOne.mockResolvedValue(null)
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'nonexistent@example.com' },
headers: { host: 'localhost:3000' }
})
const result = await loginHandler(event)
expect(result).toEqual({
success: true,
message: "If this email is registered, we've sent a login link."
})
})
it('both existing and non-existing produce same shape and message', async () => {
// Existing member
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'a@b.com' })
const event1 = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'a@b.com' },
headers: { host: 'localhost:3000' }
})
const result1 = await loginHandler(event1)
vi.clearAllMocks()
// Non-existing member
Member.findOne.mockResolvedValue(null)
const event2 = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'nobody@example.com' },
headers: { host: 'localhost:3000' }
})
const result2 = await loginHandler(event2)
// Response shape and message must be identical
expect(Object.keys(result1).sort()).toEqual(Object.keys(result2).sort())
expect(result1.success).toBe(result2.success)
expect(result1.message).toBe(result2.message)
})
it('throws 400 when email is missing from body', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: {},
headers: { host: 'localhost:3000' }
})
await expect(loginHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Email is required'
})
})
})

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn()
}))
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
}))
import { requireAuth } from '../../../server/utils/auth.js'
import Member from '../../../server/models/member.js'
import profilePatchHandler from '../../../server/api/members/profile.patch.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('members profile PATCH endpoint', () => {
const mockMember = {
_id: 'member-123',
email: 'test@example.com',
name: 'Test User',
circle: 'community',
contributionTier: 5,
pronouns: 'they/them',
timeZone: 'America/New_York',
avatar: 'https://example.com/avatar.jpg',
studio: 'Test Studio',
bio: 'Updated bio',
location: 'NYC',
socialLinks: { twitter: '@test' },
offering: { text: 'help', tags: ['code'] },
lookingFor: { text: 'feedback', tags: ['design'] },
showInDirectory: true
}
beforeEach(() => {
vi.clearAllMocks()
requireAuth.mockResolvedValue({ _id: 'member-123' })
Member.findByIdAndUpdate.mockResolvedValue(mockMember)
})
describe('field allowlist - forbidden fields are rejected', () => {
it('does not pass helcimCustomerId to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', helcimCustomerId: 'hacked-id' }
})
await profilePatchHandler(event)
const updateCall = Member.findByIdAndUpdate.mock.calls[0]
const setData = updateCall[1].$set
expect(setData).not.toHaveProperty('helcimCustomerId')
expect(setData).toHaveProperty('bio', 'new bio')
})
it('does not pass role to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', role: 'admin' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('role')
})
it('does not pass status to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', status: 'active' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('status')
})
it('does not pass email to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', email: 'hacked@evil.com' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('email')
})
it('does not pass _id to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', _id: 'different-id' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('_id')
})
})
describe('field allowlist - allowed fields pass through', () => {
it('passes allowed profile fields through', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: {
pronouns: 'they/them',
bio: 'Updated bio',
studio: 'Test Studio',
location: 'NYC',
timeZone: 'America/New_York',
avatar: 'https://example.com/avatar.jpg',
showInDirectory: true,
socialLinks: { twitter: '@test' }
}
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).toHaveProperty('pronouns', 'they/them')
expect(setData).toHaveProperty('bio', 'Updated bio')
expect(setData).toHaveProperty('studio', 'Test Studio')
expect(setData).toHaveProperty('location', 'NYC')
expect(setData).toHaveProperty('timeZone', 'America/New_York')
expect(setData).toHaveProperty('avatar', 'https://example.com/avatar.jpg')
expect(setData).toHaveProperty('showInDirectory', true)
expect(setData).toHaveProperty('socialLinks')
})
it('passes offering and lookingFor nested objects through', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: {
offering: { text: 'mentoring', tags: ['code', 'design'] },
lookingFor: { text: 'feedback', tags: ['art'] }
}
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData.offering).toEqual({ text: 'mentoring', tags: ['code', 'design'] })
expect(setData.lookingFor).toEqual({ text: 'feedback', tags: ['art'] })
})
})
})

View file

@ -0,0 +1,72 @@
import { IncomingMessage, ServerResponse } from 'node:http'
import { Socket } from 'node:net'
import { createEvent } from 'h3'
/**
* Create a real h3 event backed by real Node.js request/response objects.
*
* Options:
* method - HTTP method (default 'GET')
* path - Request path (default '/')
* headers - Object of request headers
* cookies - Object of cookie key/value pairs (serialized to cookie header)
* body - Request body (will be JSON-serialized)
* remoteAddress - Client IP (default '127.0.0.1')
*/
export function createMockEvent(options = {}) {
const {
method = 'GET',
path = '/',
headers = {},
cookies = {},
body = undefined,
remoteAddress = '127.0.0.1'
} = options
// Build cookie header from cookies object
const cookiePairs = Object.entries(cookies).map(([k, v]) => `${k}=${v}`)
if (cookiePairs.length > 0) {
headers.cookie = [headers.cookie, cookiePairs.join('; ')].filter(Boolean).join('; ')
}
// Build a real IncomingMessage
const socket = new Socket()
// remoteAddress is a getter-only property on Socket, so use defineProperty
Object.defineProperty(socket, 'remoteAddress', {
value: remoteAddress,
writable: true,
configurable: true
})
const req = new IncomingMessage(socket)
req.method = method
req.url = path
req.headers = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
)
// If body is provided, push it into the request stream so readBody can consume it
if (body !== undefined) {
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body)
req.headers['content-type'] = req.headers['content-type'] || 'application/json'
req.headers['content-length'] = Buffer.byteLength(bodyStr).toString()
req.push(bodyStr)
req.push(null)
}
const res = new ServerResponse(req)
// Capture response headers for test assertions
const setHeaders = {}
const originalSetHeader = res.setHeader.bind(res)
res.setHeader = (name, value) => {
setHeaders[name.toLowerCase()] = value
return originalSetHeader(name, value)
}
const event = createEvent(req, res)
// Attach captured headers for test access
event._testSetHeaders = setHeaders
return event
}

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')
})
})
})

34
tests/server/setup.js Normal file
View file

@ -0,0 +1,34 @@
import { vi } from 'vitest'
import {
getCookie,
setCookie,
getMethod,
getHeader,
getHeaders,
setHeader,
getRequestURL,
createError,
defineEventHandler,
readBody,
getQuery,
getRouterParam
} from 'h3'
// Register real h3 functions as globals so server code that relies on
// Nitro auto-imports can find them in the test environment.
vi.stubGlobal('getCookie', getCookie)
vi.stubGlobal('setCookie', setCookie)
vi.stubGlobal('getMethod', getMethod)
vi.stubGlobal('getHeader', getHeader)
vi.stubGlobal('getHeaders', getHeaders)
vi.stubGlobal('setHeader', setHeader)
vi.stubGlobal('getRequestURL', getRequestURL)
vi.stubGlobal('createError', createError)
vi.stubGlobal('defineEventHandler', defineEventHandler)
vi.stubGlobal('readBody', readBody)
vi.stubGlobal('getQuery', getQuery)
vi.stubGlobal('getRouterParam', getRouterParam)
vi.stubGlobal('useRuntimeConfig', () => ({
jwtSecret: 'test-jwt-secret'
}))

View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findById: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() }
}))
import Member from '../../../server/models/member.js'
import jwt from 'jsonwebtoken'
import { requireAuth, requireAdmin } from '../../../server/utils/auth.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('requireAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws 401 when no auth-token cookie', async () => {
const event = createMockEvent({ path: '/api/test' })
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Authentication required'
})
})
it('throws 401 when JWT is invalid', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid token') })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'bad-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('throws 401 when member not found in DB', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(null)
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Member not found'
})
})
it('throws 403 when member is suspended', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue({ _id: 'member-123', status: 'suspended', role: 'member' })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is suspended'
})
})
it('throws 403 when member is cancelled', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue({ _id: 'member-123', status: 'cancelled', role: 'member' })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is cancelled'
})
})
it('returns member when token and status are valid', async () => {
const mockMember = { _id: 'member-123', status: 'active', role: 'member', email: 'test@example.com' }
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
const result = await requireAuth(event)
expect(result).toEqual(mockMember)
})
})
describe('requireAdmin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws 403 when member is not admin', async () => {
const mockMember = { _id: 'member-123', status: 'active', role: 'member' }
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/admin/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAdmin(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Admin access required'
})
})
it('returns member when role is admin', async () => {
const mockMember = { _id: 'admin-123', status: 'active', role: 'admin', email: 'admin@example.com' }
jwt.verify.mockReturnValue({ memberId: 'admin-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/admin/test',
cookies: { 'auth-token': 'valid-token' }
})
const result = await requireAdmin(event)
expect(result).toEqual(mockMember)
})
})

View file

@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { escapeHtml } from '../../../server/utils/escapeHtml.js'
describe('escapeHtml', () => {
it('escapes ampersands', () => {
expect(escapeHtml('a&b')).toBe('a&amp;b')
})
it('escapes less-than signs', () => {
expect(escapeHtml('a<b')).toBe('a&lt;b')
})
it('escapes greater-than signs', () => {
expect(escapeHtml('a>b')).toBe('a&gt;b')
})
it('escapes double quotes', () => {
expect(escapeHtml('a"b')).toBe('a&quot;b')
})
it('escapes single quotes', () => {
expect(escapeHtml("a'b")).toBe('a&#39;b')
})
it('escapes all entities in a single string', () => {
expect(escapeHtml('<div class="x">&\'test\'')).toBe(
'&lt;div class=&quot;x&quot;&gt;&amp;&#39;test&#39;'
)
})
it('returns empty string for null', () => {
expect(escapeHtml(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(escapeHtml(undefined)).toBe('')
})
it('converts numbers to string', () => {
expect(escapeHtml(42)).toBe('42')
})
it('passes safe strings through unchanged', () => {
expect(escapeHtml('hello world')).toBe('hello world')
})
it('neutralizes script tag XSS payload', () => {
const payload = '<script>alert("xss")</script>'
const result = escapeHtml(payload)
expect(result).not.toContain('<script>')
expect(result).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
it('neutralizes img onerror XSS payload', () => {
const payload = '<img onerror="alert(1)" src=x>'
const result = escapeHtml(payload)
expect(result).not.toContain('<img')
expect(result).toBe('&lt;img onerror=&quot;alert(1)&quot; src=x&gt;')
})
})

25
vitest.config.js Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
test: {
name: 'server',
include: ['tests/server/**/*.test.js'],
environment: 'node',
globals: true,
setupFiles: ['./tests/server/setup.js']
}
},
{
test: {
name: 'client',
include: ['tests/client/**/*.test.js'],
environment: 'jsdom',
globals: true
}
}
]
}
})