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/auth_service.go (checkDeviceFingerprint). See Security → Device Fingerprint.
SQL injection¶
Threat. Malicious input crafted to alter or read SQL beyond the intended query.
Mitigation.
- sqlc-generated query construction. Each domain's
store.goruns generated queries whose values are always bound as positional parameters ($1, $2, ...), never string-concatenated. Raw SQL is restricted to a small set of PostGIS queries that also bind their arguments, verifiable by grepping the store files for inline SQL (grep -rn 'pool.Query\|pool.Exec\|\.Query(\|\.Exec(' backend/internal/services/*/*store*.go). - 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, the store.go files across backend/internal/services/.
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/services/media/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")
}
}
Beyond this endpoint-level check, all outbound calls route through httpx.SafeClient, which re-checks the resolved IP at dial time (defeating DNS rebinding). See Security → Outbound HTTP.
Where. backend/internal/services/media/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/internal/services/event/event_handler.go (GetEventSharePage), 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/auth_service.go, backend/internal/services/auth/apikey_store.go, backend/config/config.go (SecurityConfig.EncryptionKey).
Privilege escalation¶
Threat. A non-admin user calls an admin endpoint.
Mitigation. Two-layer auth:
JWTAuth(middleware/auth.go) validates the bearer token and setsuser_id,account_type, androleon the request context. It also compares the token's version againstCurrentTokenVersion, so bumping a user's token version invalidates every issued token (the revocation lever).access.Require(policy)gates privileged routes on the(account_type, role)pair read from that context, with no per-request DB call./admin/*mountsaccess.Require(access.TomodaAdmin)(account typetomoda, roleadmin); curator, auditor, operator, and support surfaces mount their own named policies. Partner-scoped routes use membership-based gates (access.RequirePartnerAdmin, etc.) that do read the partner membership.
Where. backend/internal/access/access.go, backend/internal/access/policies.go, backend/internal/access/partner.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 |
|---|---|---|
| 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 to spot abuse that slips through these gates. For incident response, see Getting Started (app-side procedures) and the DevOps Runbook (cluster-side).