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
282
docs/SECURITY_EVALUATION.md
Normal file
282
docs/SECURITY_EVALUATION.md
Normal 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
1123
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
tests/client/composables/useMarkdown.test.js
Normal file
110
tests/client/composables/useMarkdown.test.js
Normal 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('')
|
||||||
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
113
tests/server/api/auth-login.test.js
Normal file
113
tests/server/api/auth-login.test.js
Normal 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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
157
tests/server/api/members-profile-patch.test.js
Normal file
157
tests/server/api/members-profile-patch.test.js
Normal 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'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
72
tests/server/helpers/createMockEvent.js
Normal file
72
tests/server/helpers/createMockEvent.js
Normal 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
|
||||||
|
}
|
||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
34
tests/server/setup.js
Normal file
34
tests/server/setup.js
Normal 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'
|
||||||
|
}))
|
||||||
142
tests/server/utils/auth.test.js
Normal file
142
tests/server/utils/auth.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
tests/server/utils/escapeHtml.test.js
Normal file
60
tests/server/utils/escapeHtml.test.js
Normal 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&b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes less-than signs', () => {
|
||||||
|
expect(escapeHtml('a<b')).toBe('a<b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes greater-than signs', () => {
|
||||||
|
expect(escapeHtml('a>b')).toBe('a>b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes double quotes', () => {
|
||||||
|
expect(escapeHtml('a"b')).toBe('a"b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes single quotes', () => {
|
||||||
|
expect(escapeHtml("a'b")).toBe('a'b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes all entities in a single string', () => {
|
||||||
|
expect(escapeHtml('<div class="x">&\'test\'')).toBe(
|
||||||
|
'<div class="x">&'test''
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('<script>alert("xss")</script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('<img onerror="alert(1)" src=x>')
|
||||||
|
})
|
||||||
|
})
|
||||||
25
vitest.config.js
Normal file
25
vitest.config.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue