Adds schema-based input validation across helcim, events, members, series, admin, and updates API endpoints. Removes the peer-support debug test endpoint. Adds validation test coverage.
22 KiB
Ghost Guild Security Evaluation
Date: 2026-02-28 (Phases 0-3 complete as of 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 noroleorisAdminfield.
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 viav-htmlwith 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 |
| 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-2)
All work lives on branch security/asvs-remediation.
Auth guards (server/utils/auth.js)
requireAuth(event)-- Reads JWT fromauth-tokencookie, verifies againstjwtSecret, loads member from DB, rejects suspended/cancelled accounts (403). Auto-imported by Nitro.requireAdmin(event)-- CallsrequireAuth, then checksmember.role === 'admin'(403 if not). Member model gainedrolefield (enum:member/admin, defaultmember).- Applied to all
server/api/admin/,server/api/upload/, andserver/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 matchingx-csrf-tokenheader. - Client plugin reads the cookie and attaches the header on every
$fetchrequest. - 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-flexiblewith 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-Afterheader 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 inserver/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,_idare excluded from the$setupdate.
Session management
- 7-day token expiry with refresh endpoint at
/api/auth/refresh.
Input validation (server/utils/schemas.js + server/utils/validateBody.js)
- Centralized Zod schemas for all API endpoints.
validateBody(event, schema)replacesreadBodyand returns only validated fields (stripping unknown properties). memberCreateSchema: Explicit allowlist (email, name, circle, contributionTier). Prevents mass assignment ofrole,status,helcimCustomerId,_id.emailSchema: Trims, lowercases, and validates email format.memberProfileUpdateSchema: Type/length validation on all profile fields and privacy enum values.updateCreateSchema: Content length limit (1–50000 chars), image URL array (max 20).adminEventCreateSchema: Required title, description, dates; optional capacity/location/pricing/tickets.paymentVerifySchema: Validates cardToken and customerId presence.
Additional hardening
- Logout cookie flags now match login flags (
httpOnly: true,secureconditional on NODE_ENV). - Removed 3 unauthenticated test/debug endpoints (
test-connection,test-subscription,test-bot). - Removed sensitive
console.logstatements from Helcim and member creation endpoints. - Removed unused
bcryptjsdependency. - Added 10MB file size limit on image uploads.
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) -- COMPLETE
| # | Finding | Fix | ASVS | Status |
|---|---|---|---|---|
| 15 | H7 | Implement Zod validation across all API endpoints | V5.1.3 | Done |
| 16 | M5 | Escape regex in directory search | V5.3.4 | Done |
| 17 | M4 | Remove sensitive console.log statements | V7.1.1 | Done |
| 18 | M3 | Make devtools conditional on NODE_ENV | V7.4.1 | Done |
| 19 | M1 | Shorter session tokens (7d) with refresh endpoint | V2.5.2 | Done |
| 20 | -- | Create shared requireAuth()/requireAdmin() utilities |
V4.1.1 | Done |
| 21 | -- | Fix mass assignment in member creation (new Member(body)) |
V5.1.3 | Done |
| 22 | -- | Fix logout cookie flags to match login (httpOnly, secure) | V3.2.1 | Done |
| 23 | -- | Remove unauthenticated test/debug endpoints | V4.1.1 | Done |
| 24 | -- | Remove dead bcryptjs dependency |
-- | Done |
| 25 | -- | Add 10MB file size limit on image uploads | V13.1.1 | Done |
Phase 3: Remaining hardening -- COMPLETE
| # | Severity | Fix | ASVS | Status |
|---|---|---|---|---|
| 26 | HIGH | create-plan.post.js has no auth guard -- anyone can create Helcim payment plans |
V4.1.1 | Done |
| 27 | HIGH | plans.get.js and subscriptions.get.js have no auth -- expose Helcim plan/subscription data |
V4.1.1 | Done |
| 28 | MEDIUM | create.post.js returns full Mongoose member document in response (leaks role, status, helcimCustomerId, internal fields) |
V5.1.2 | Done |
| 29 | MEDIUM | Helcim error text forwarded to client in statusMessage across 7 endpoints (create-plan, customer-code, initialize-payment, subscription, update-billing, customer, get-or-create-customer) |
V7.4.1 | Done |
| 30 | MEDIUM | tickets/purchase.post.js and 24 other endpoints still use raw readBody without Zod validation |
V5.1.3 | Done |
| 31 | LOW | server/api/test/peer-support-debug.get.js is a debug endpoint in a test/ directory -- has auth but should be removed before production |
V14.2.2 | Done |
Phase 3 remediation details:
- Items 26-27: Added
await requireAdmin(event)tocreate-plan.post.js,plans.get.js, andsubscriptions.get.js. - Item 28: Member creation response now returns an explicit projection (
id,email,name,circle,contributionTier,status). Also fixedsubscription.post.js(5 return paths) andupdate-contribution.post.js(4 return paths) to not expose full member documents. Fixedget-or-create-customer.post.jserror text forwarding. - Item 29: Replaced all
`Failed to X: ${errorText}`patterns with generic client messages. Also fixed outer catch blocks that forwardederror.message-- these now re-throw knowncreateErrorinstances and use generic messages for unknown errors. - Item 30: Added 25 new Zod schemas to
server/utils/schemas.jsand migrated all 25 endpoints fromreadBodytovalidateBody. Total schema count: 32 (7 from Phase 2 + 25 from Phase 3). All POST/PUT/PATCH/DELETE endpoints with request bodies now use Zod validation. - Item 31: Deleted
server/api/test/peer-support-debug.get.jsand theserver/api/test/directory.
Phase 4: Before building planned features
| # | Fix | Status |
|---|---|---|
| 32 | Build sanitization utility (DOMPurify wrapper) for all user-generated HTML | Open |
| 33 | Design admin role model with granular permissions | Open |
| 34 | Implement file validation pipeline (type, size, virus scanning) | Open |
| 35 | 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
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 stubsuseRuntimeConfig.tests/server/helpers/createMockEvent.js-- Factory that builds real h3 events from Node.jsIncomingMessage/ServerResponsepairs. Acceptsmethod,path,headers,cookies,body, andremoteAddress. Captures response headers viaevent._testSetHeadersfor assertions.
Test Coverage (213 tests across 12 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 via Zod (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) |
tests/server/api/validation.test.js |
38 | Mass assignment: role, status, helcimCustomerId, _id stripped from member create; email format validation on login/register/create; content length limits; image array limits; payment token validation; enum validation for circles, tiers, privacy; validateBody integration (H7, V5.1.3) |
tests/server/api/helcim-auth.test.js |
6 | create-plan.post.js, plans.get.js, subscriptions.get.js all call requireAdmin before business logic (Items 26-27, V4.1.1) |
tests/server/api/members-create-response.test.js |
9 | Response projection: no raw member return, explicit _id/email/name/circle/status fields, no helcimCustomerId or role in response, no error.message forwarding (Item 28, V5.1.2) |
tests/server/api/validation-phase3.test.js |
81 | 25 new Zod schemas: invalid input rejected, valid input passes, unknown fields stripped, mass assignment prevented; error text forwarding regression checks on 8 Helcim endpoints; validateBody migration verified across all 25 migrated endpoints (Items 29-30, V5.1.3, V7.4.1) |
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.
Item 21 (Mass assignment): curl -X POST /api/members/create -d '{"email":"test@test.com","name":"Test","circle":"community","contributionTier":"0","role":"admin"}' -H 'Content-Type: application/json' -- response member must have role: "member", not "admin".
Item 22 (Logout cookie): Log in, verify auth-token cookie exists. Log out, verify auth-token cookie is cleared. document.cookie must not contain auth-token at any point.
Item 23 (Deleted endpoints): curl /api/helcim/test-connection, /api/helcim/test-subscription, /api/slack/test-bot must all return 404.
Item 25 (Upload size): Upload a file >10MB to /api/upload/image -- must return 400 "File too large".
Integration verification (after each phase)
npm run test:run-- all 213 tests passnpm run buildsucceeds- 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
curlagainst hardened endpoints: unauthenticated = rejected
Notes for Future Rounds
Zod v4 behavior to be aware of
- Transform ordering matters.
.trim().toLowerCase()must come BEFORE.email(). Zod v4 runs transforms in chain order, so.email().trim()validates the untrimmed string first and rejects" user@example.com ". This is opposite to some other validation libraries. .optional()does NOT acceptnull. Onlyundefined. If the frontend sendsnullto clear a field, Zod rejects it. Use.nullable()or.nullish()if null-clearing is needed. Audit frontend form behavior before migrating remaining endpoints.- Unknown keys are stripped, not rejected.
z.object()silently drops fields not in the schema. This is the desired behavior for mass assignment prevention, but means typo'd field names from the frontend will be silently ignored rather than producing an error. Use.strict()on schemas if you want to reject unexpected fields.
Patterns that recurred during Phases 2-3
- Auth guard gaps. (Fixed in Phase 3, items 26-27.) Every new endpoint needs
requireAuthorrequireAdmin. Recommendation: add a grep check to CI that flags anyserver/api/handler not containingrequireAuth,requireAdmin, or an explicit// @publiccomment. - Error text forwarding. (Fixed in Phase 3, item 29.) The pattern
statusMessage: `Failed to X: ${errorText}`leaks upstream API error details to the client. Standard pattern is now: generic client message +console.errorfor server-side logging. - Response over-exposure. (Fixed in Phase 3, item 28.)
return { success: true, member }sends the full Mongoose document. Standard pattern is now: explicit projection to allowlisted fields. readBodyaudit. (Fixed in Phase 3, item 30.) All POST/PUT/PATCH/DELETE endpoints now usevalidateBodywith Zod schemas. 32 total schemas across 32 endpoints.- Debug endpoints. (Fixed in Phase 3, item 31.)
server/api/test/directory deleted. Consider a build-time check or.gitignorepattern to prevent futureserver/api/test/additions.