Security Documentation - SetTimes.ca¶
Last Updated: 2026-04-30 Version: 2.2 (April 2026 Review)
Table of Contents¶
- Security Overview
- Critical Security Fixes
- Authentication & Authorization
- Session Management
- CSRF Protection
- Input Validation & Sanitization
- Content Security Policy
- CORS Configuration
- Deployment Security
- Security Best Practices
Security Overview¶
This application implements defense-in-depth security with multiple layers of protection:
- ✅ HTTPOnly session cookies - Protects against XSS token theft
- ✅ CSRF tokens - Prevents cross-site request forgery
- ✅ Invite-only signup - Prevents unauthorized account creation
- ✅ Content Security Policy - Mitigates XSS and injection attacks
- ✅ Strict CORS - Prevents unauthorized cross-origin requests
- ✅ PBKDF2 password hashing - 100,000 iterations with SHA-256
- ✅ Role-based access control - Admin, Editor, Viewer roles
- ✅ TOTP MFA - Time-based one-time password with backup codes
- ✅ Trusted device - 30-day device remembrance validated by IP + User-Agent hash
- ✅ Comprehensive audit logging - All actions tracked
- ✅ Rate limiting - Prevents brute force attacks
- ✅ DOMPurify - Rich-text HTML sanitization
Critical Security Fixes¶
P0-1: Invite-Only Signup System¶
Problem: Public signup endpoint allowed unlimited account creation.
Solution: Implemented invite code system requiring valid, unexpired invite codes for all signups.
Files Changed:
- database/migration-invite-codes.sql - Invite codes table
- functions/api/admin/invite-codes.js - Admin invite management
- functions/api/admin/auth/signup.js - Invite code validation
Usage:
# Create invite code
node scripts/create-admin-invite.js --prod
# Insert into database
wrangler d1 execute settimes-db --command="INSERT INTO invite_codes..."
# Use during signup
POST /api/admin/auth/signup
{
"email": "user@example.com",
"password": "<your-strong-password>",
"name": "User Name",
"inviteCode": "generated-uuid-here"
}
P0-2: Removed Hardcoded Credentials¶
Problem: Default admin credentials were hardcoded in migration files and docs.
Solution: Removed plaintext credentials. Demo passwords are now set locally via environment variables during setup.
Files Changed:
- database/migration-rbac-sprint-1-1.sql - Removed default admin
- scripts/create-admin-invite.js - Helper script for first-time setup
P0-3: HTTPOnly Cookies + CSRF Protection¶
Problem: Session tokens stored in sessionStorage, vulnerable to XSS attacks.
Solution: Migrated to HTTPOnly cookies with double-submit CSRF token pattern.
Files Changed:
- functions/utils/cookies.js - Cookie utilities
- functions/utils/csrf.js - CSRF token generation/validation
- functions/api/admin/auth/login.js - Set HTTPOnly cookie
- functions/api/admin/auth/signup.js - Set HTTPOnly cookie
- functions/api/admin/auth/logout.js - Clear cookies
- functions/api/admin/_middleware.js - Read cookie, validate CSRF
- frontend/src/utils/adminApi.js - Use cookies instead of sessionStorage
Flow:
1. Login/signup returns CSRF token in JSON and sets HTTPOnly session cookie
2. Client stores CSRF token in memory
3. Client sends CSRF token in X-CSRF-Token header with state-changing requests
4. Server validates: cookie token exists AND matches header token
5. Logout clears both cookies
P0-4: Content Security Policy (CSP)¶
Problem: CSP disabled, leaving app vulnerable to XSS and injection attacks.
Solution: Enabled strict CSP with minimal unsafe directives.
Files Changed:
- backend/server.js - Helmet CSP configuration
CSP Directives:
{
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Tailwind needs inline
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: []
}
P1-8: Fixed CORS Validation¶
Problem: CORS middleware set Access-Control-Allow-Origin even for invalid origins.
Solution: Strict origin validation - only allowed origins receive CORS headers.
Files Changed:
- functions/_middleware.js - Validate origin before setting headers
Allowed Origins:
- Production: https://settimes.ca, https://www.settimes.ca
- Preview: https://dev.settimes.pages.dev, https://settimes.pages.dev
- Local: http://localhost:5173, http://localhost:3000, http://localhost:8788
P1-10: Service Worker Security¶
Problem: Service worker cached sensitive admin API responses.
Solution: Exclude all /api/admin/ routes from caching.
Files Changed:
- frontend/public/sw.js - Never cache admin routes
Authentication & Authorization¶
User Roles¶
| Role | Permissions |
|---|---|
| admin | Full access: manage users, all CRUD operations, view audit logs |
| editor | Create/edit events, bands, venues; cannot manage users |
| viewer | Read-only access to admin panel |
Role Hierarchy¶
admin (level 3) > editor (level 2) > viewer (level 1)
Higher roles inherit lower role permissions.
Permission Checks¶
All admin endpoints use checkPermission() middleware:
const permCheck = await checkPermission(request, env, "editor");
if (permCheck.error) {
return permCheck.response; // 401 or 403
}
Session Management¶
Session Cookies¶
Sessions are managed by Lucia auth v3 using a Cloudflare D1 (lucia_sessions) backend.
| Property | Value |
|---|---|
| Production cookie name | __Host-session_token |
| Dev cookie name | session_token |
| HTTPOnly | Yes (not accessible to JavaScript) |
| Secure | Yes in production (enforced by __Host- prefix) |
| SameSite | Strict (prevents CSRF) |
| Expiry | 30 days (absolute); sliding on activity |
The __Host- prefix also enforces Path=/ and prohibits a Domain attribute, preventing subdomain injection attacks.
MFA & Trusted Devices¶
When TOTP MFA is enabled, login is a two-step flow:
1. Password check → issues a short-lived mfa_challenges token (5 minutes)
2. TOTP/backup-code verification → issues the full Lucia session
Optionally, users can choose "Remember this device" to skip MFA for 30 days. Trusted device tokens are stored as __Host-trusted_device (SHA-256 hashed in DB) and validated against the stored SHA-256 hashes of the IP address and User-Agent string from when the device was registered.
Session Lifecycle¶
- Creation: Successful MFA verification calls
lucia.createSession(userId, {}) - Storage: Session ID stored in
lucia_sessionstable with IP + UA metadata - Validation:
_middleware.jsreads the session cookie withlucia.readSessionCookie()and validates the resulting ID withlucia.validateSession(sessionId)on every admin request - Activity:
last_used_atupdated on successful validation; sessions slide on activity - Expiration: Sessions expire after 30 days of inactivity
- Logout:
lucia.invalidateSession()deletes the DB row; cookies cleared
CSRF Protection¶
Double-Submit Cookie Pattern¶
- Server generates CSRF token on login/signup
- Server sends token in JSON response AND sets
csrf_tokencookie (NOT HttpOnly) - Client stores token in memory
- Client sends token in
X-CSRF-Tokenheader with requests - Server validates: cookie value === header value
Validation Rules¶
- Required for: POST, PUT, DELETE, PATCH
- Skipped for: GET, HEAD, OPTIONS, auth endpoints
- Failure: 403 Forbidden
Implementation¶
// Server: Generate and send
const csrfToken = generateCSRFToken();
headers.append("Set-Cookie", setCSRFCookie(csrfToken));
return { ...response, csrfToken };
// Client: Send with requests
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
}
// Server: Validate
const valid = validateCSRFToken(request);
if (!valid) return 403;
Input Validation & Sanitization¶
Current Validation¶
File: frontend/src/utils/validation.js
- DOMPurify sanitizes all rich-text HTML before it is stored or rendered ✅
- Parameterized queries for all D1 SQL ✅
- React escapes plain-text output automatically ✅
- Server-side validation on all admin endpoints (email format, required fields, length limits)
Server-Side Validation¶
All endpoints validate:
- Email format (/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
- Password length (8+ characters)
- Required fields
- Data types
- Length limits (future enhancement)
Content Security Policy¶
Policy Goals¶
- Prevent XSS attacks
- Block inline script execution
- Restrict resource loading
- Prevent clickjacking
Configuration¶
Location: frontend/public/_headers (Cloudflare Pages)
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Tailwind CSS
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"]
}
}
})
Notes¶
'unsafe-inline'for styles: Required by Tailwind CSS- Consider using nonces for inline scripts if needed
- Test thoroughly after any CSP changes
CORS Configuration¶
Allowed Origins¶
Configured in: functions/_middleware.js
Production:
- https://settimes.ca
- https://www.settimes.ca
Preview/Staging:
- https://dev.settimes.pages.dev
- https://settimes.pages.dev
Local Development:
- http://localhost:5173 (Vite)
- http://localhost:3000 (Express)
- http://localhost:8788 (Wrangler)
- http://127.0.0.1:* (all above)
Credentials¶
CORS requests with credentials (credentials: 'include') only allowed from approved origins.
Deployment Security¶
Environment Variables¶
Required secrets in Cloudflare Pages (set via dashboard, never commit):
CSRF_SECRET=<32+ byte random hex> # HMAC key for CSRF tokens
MFA_ENCRYPTION_KEY=<32 byte hex> # AES-256 key for TOTP secrets at rest
TURNSTILE_SECRET_KEY=<from Cloudflare> # Bot protection for public forms
CF_PAGES_API_TOKEN=<from Cloudflare> # CI deployment token (GitHub secret)
CF_ACCOUNT_ID=<from Cloudflare> # CI deployment (GitHub secret)
Optional:
ENVIRONMENT=development # Set in dev environment to relax __Host- cookie prefix
RESEND_API_KEY=<key> # Email delivery for subscription notifications
Database Setup¶
-
Create D1 database:
wrangler d1 create settimes-db -
Run migrations:
wrangler d1 execute settimes-db --file=database/schema.sql wrangler d1 execute settimes-db --file=database/migration-rbac-sprint-1-1.sql wrangler d1 execute settimes-db --file=database/migration-invite-codes.sql -
Create first admin invite:
node scripts/create-admin-invite.js --prod wrangler d1 execute settimes-db --command="INSERT INTO invite_codes (code, role, expires_at, is_active) VALUES ('UUID-HERE', 'admin', datetime('now', '+7 days'), 1);" -
Sign up with invite code at your production URL
R2 Bucket (Band Photos)¶
wrangler r2 bucket create settimes-band-photos
Bind in Cloudflare Pages dashboard: Settings > Functions > R2 bucket bindings
Security Best Practices¶
For Developers¶
- Never commit secrets - Use
.env,.dev.vars(gitignored) - Use parameterized queries - Already implemented ✅
- Validate all inputs - Server-side validation required
- Sanitize outputs - Use DOMPurify or React's built-in escaping
- Review pull requests - Security-focused code review
- Run security tests - OWASP ZAP, penetration testing
- Update dependencies -
npm audit, Dependabot - Follow principle of least privilege - Minimal permissions
- Log security events - Audit log already implemented ✅
- Test authentication flows - Automated and manual testing
For Administrators¶
- Use strong passwords - 16+ characters, password manager
- Enable 2FA - Use TOTP via the admin panel settings
- Rotate passwords - Every 3-6 months
- Review audit logs - Monthly security review
- Limit admin accounts - Only trusted personnel
- Revoke access immediately - When someone leaves
- Monitor failed logins - Check
auth_attemptstable - Secure invite codes - Never share via email/SMS
- Use HTTPS always - Never access admin panel over HTTP
- Keep backups - Regular D1 database backups
Security Incident Response¶
If Breach Suspected¶
- Immediate Actions:
- Disable affected accounts
- Rotate all passwords
- Revoke all sessions
-
Review audit logs
-
Investigation:
- Check
auth_attemptstable - Review
audit_logtable - Analyze access patterns
-
Identify attack vector
-
Remediation:
- Patch vulnerabilities
- Update dependencies
- Strengthen affected controls
-
Deploy fixes
-
Post-Incident:
- Document incident
- Update security procedures
- Train team
- Notify affected users (if required)
Security Checklist for Production¶
- [ ] All P0 security fixes applied
- [ ] Environment variables set in Cloudflare Pages
- [ ] Database migrations run
- [ ] First admin account created via invite code
- [ ] HTTPS enforced (automatic with Cloudflare)
- [ ] CSP enabled and tested
- [ ] CORS restricted to production domains
- [ ] Session cookies HTTPOnly
- [ ] CSRF protection enabled
- [ ] Audit logging working
- [ ] Rate limiting configured
- [ ] Dependencies updated
- [ ] Security testing completed
- [ ] Backup strategy implemented
- [ ] Incident response plan documented
Future Security Enhancements¶
Priority 1 (Next Sprint)¶
- [ ] Implement email verification
- [ ] Sliding session expiration
- [ ] Account lockout after failed logins
- [ ] Password complexity requirements (12+ chars, mixed case, numbers, symbols)
Priority 2 (Next Month)¶
- [ ] DOMPurify for HTML sanitization
- [ ] Server-side input length limits
- [ ] Generic error messages (prevent email enumeration)
- [ ] Complete audit logging (all operations)
- [ ] API rate limiting per endpoint
Priority 3 (Backlog)¶
- [ ] WebAuthn/Passkey support
- [ ] Security headers review
- [ ] Automated security scanning in CI/CD
- [ ] Penetration testing
- [ ] Bug bounty program
Contact¶
Security Issues: Report to [security@settimes.ca] or create a private security advisory on GitHub.
General Questions: Create an issue on GitHub with the security label.
Remember: Security is a continuous process, not a one-time fix. Regularly review and update security measures.