Threat Model¶
What we're defending against, what we do about it, and where in the code the defense lives. This is the high-level catalogue — operational tuning of each control lives in the linked sub-pages.
Credential stuffing and brute force¶
Threat. Attackers replay credential lists against /auth/login, /auth/webauthn/login/begin, /auth/otp/verify, etc., or hammer registration to enumerate emails.
Mitigation.
- Per-IP rate limits — 10 logins / 15 min, 5 registrations / hour, 5 password resets / hour.
- Bcrypt password hashing with a default cost factor — slow by design, takes years to brute force a single hash.
- Failed logins logged in the audit trail (
AuditService) so anomalies surface in analytics. - Auto-IP-block after 5 rate-limit violations within an hour (24-hour block).
Where. backend/internal/middleware/rate_limiter.go, backend/internal/middleware/ip_blocker.go. See Security → Rate Limiting.
Account farming / sybil attacks¶
Threat. Mass account creation to game social proof, spam events, or harvest invitations.
Mitigation. Layered, independent gates:
- IP rate limit (5 registrations / hour).
- Email OTP at signup — must own the address.
- Device fingerprint cap — 3 accounts per device per 30 days.
- Optional phone verification for event creation (extra friction).
Where. backend/internal/services/auth_service.go (checkDeviceFingerprint). See Security → Device Fingerprint.
SQL injection¶
Threat. Malicious input crafted to alter or read SQL beyond the intended query.
Mitigation.
- GORM-only query construction. Repositories use the query builder, which always parameterises values. Raw SQL is restricted to a small set of PostGIS queries that use
?placeholders — verifiable bygrep "db.Raw\|gorm.io"acrossbackend/internal/repository/. - Heuristic block at the edge —
IPBlocker.SuspiciousActivityMiddlewaredetects classic injection signatures (' OR '1'='1,UNION SELECT, etc.) in query params and 403s the request with a 48-hour IP block.
Where. backend/internal/middleware/ip_blocker.go, every file in backend/internal/repository/.
SSRF (server-side request forgery)¶
Threat. The /api/v1/link-preview endpoint fetches arbitrary URLs to extract OG metadata. Without protection, it could reach internal services (metadata endpoints, internal HTTP APIs).
Mitigation.
- Scheme allowlist —
httpsonly. - Hostname resolution + private-IP rejection —
net.LookupHost, then for each resolved IP, reject if it falls in any RFC1918 / loopback / link-local / multicast range.
// backend/internal/handlers/link_preview_handler.go
if u.Scheme != "https" { return fmt.Errorf("only https URLs are allowed") }
hostname := u.Hostname()
addrs, _ := net.LookupHost(hostname)
for _, addr := range addrs {
ip := net.ParseIP(addr)
if ip == nil || isPrivateIP(ip) {
return fmt.Errorf("target resolves to a private IP address")
}
}
Where. backend/internal/handlers/link_preview_handler.go::validateURL.
CSRF¶
Threat. A malicious site causes an authenticated user's browser to make state-changing requests.
Mitigation. Largely not applicable for our API:
- Auth is bearer-token, not cookie. Browsers don't auto-attach
Authorizationheaders across origins. - CORS allows
*in dev (locked down in prod), but credentials in headers must be explicitly set by the calling code — there's no implicit auth. - The only cookie-style risks would be on the webhook endpoints, which use their own signing/verification (Stripe webhook signature, Apple notification JWT signature).
We do not issue or accept CSRF tokens.
XSS¶
Threat. User-supplied content rendered into HTML executes attacker JavaScript.
Mitigation.
- API returns JSON, never HTML. The only HTML the backend emits is the public event share page (
/api/v1/share/events/:id), which renders through Go'shtml/template(auto-escaping) and only includes fields validated server-side. X-Content-Type-Options: nosniffandX-Frame-Options: DENYon every response viaSecurityHeadersmiddleware.- HSTS (
Strict-Transport-Security: max-age=31536000; includeSubDomains) on production responses. - Frontend uses React (auto-escapes JSX) and adds a CSP at the document level — see frontend docs.
Where. backend/internal/middleware/security.go, backend/templates/og.html.
Bot / spam¶
Threat. Automated event spam, fake messages, drive-by signups.
Mitigation.
- Email OTP required for signup, password reset, and email change — eliminates throwaway-without-email-access flows.
- Rate limits on the public auth surface (above).
- Suspicious-activity middleware catches obvious automated scanning.
- Audit log of all activity (
AuditService) gives ops a paper trail for after-the-fact takedowns.
Data exposure at rest¶
Threat. A DB dump exposes passwords, API keys, or PII directly.
Mitigation.
- Passwords: bcrypt-hashed, never stored plaintext.
- API keys (for the
/api/v1/auth/api-keysfeature): the plaintext is shown to the user once at create time; what we store is the SHA-256 hash for lookup plus an AES-GCM-encrypted copy keyed byENCRYPTION_KEY(inSecurity.EncryptionKey). Compromising the DB alone is not enough — the encryption key is in GCP Secret Manager, separate. - OAuth tokens (Google, Apple, LINE access tokens, when persisted) are encrypted at rest using the same key.
- JWTs are never stored — they're verified statelessly and forgotten.
- Refresh tokens are stored hashed in
RefreshTokenand looked up by hash. - PII minimisation — we keep email, optional phone, optional display name, and an avatar URL. No address, no DOB, no payment card data (Stripe holds card details).
Where. backend/internal/services/auth_service.go, backend/internal/repository/api_key_repository.go, backend/config/config.go (SecurityConfig.EncryptionKey).
Privilege escalation¶
Threat. A non-admin user calls an admin endpoint.
Mitigation. Two-layer auth:
JWTAuthresolves the user from the bearer token.AdminAuth(mounted on/admin/*) re-reads the user from the DB and checksIsAdmin == true. Re-reading rather than trusting the JWT claim means privileges can be revoked instantly.
Where. backend/internal/middleware/admin.go.
Transport security¶
Threat. Plain-HTTP requests, downgrade attacks, missing certificate pinning.
Mitigation. TLS is terminated by the load balancer (Cloud Run / GKE Ingress). HSTS is enabled in production. Mobile clients pin to LetsEncrypt issuers transitively (no static cert pinning today).
Out of scope / known gaps¶
| Gap | Impact | Mitigation status |
|---|---|---|
| WebSocket multi-instance fan-out | Chat splits across replicas | Single-replica deploy as workaround |
| Per-user rate limits on authenticated routes | Compromised account can spam | Rely on IP + audit log |
| Captcha on registration | Determined attacker can defeat the email OTP loop | Considered for next milestone |
| Production WAF | No L7 inspection beyond our middleware | LB-level WAF on the roadmap |
Use the audit log + alerting rules in operations to spot abuse that slips through these gates. For incident response, see Operations → Runbook.