Security
Threat model, secrets catalog, rate limiting, and incident response. Cryptographic details for auth tokens live in features/tokens.md; the scheme overview is in AUTH.md.
1. Threat model
Who we're defending against, in priority order:
- Scripted abusers — bots hammering
/auth/register,/auth/login, or the submit endpoint. Rate-limited with@nestjs/throttler; self-registration closable via env. - Opportunistic attackers with stolen tokens — refresh-token rotation + reuse detection catches most replays.
- Malicious authenticated users — permissions enforced via
PermissionsGuard; soft-delete + audit trail on destructive actions. - Supply-chain attacks on npm dependencies — lockfile +
npm auditin CI (future); minimal dependency surface. - Infrastructure compromise — out of scope for this doc; depends on the hosting provider.
Not in scope:
- Nation-state-level attackers.
- Custom malware on a user's device (we cannot protect tokens in
chrome.storage.localagainst local malware — the OS-level threat is theirs, not ours). - DDoS at network layer (hosting provider concern).
2. Secrets catalog
Every secret the system handles, where it lives, and how it rotates.
| Secret | Storage | Handling | Rotation |
|---|---|---|---|
JWT_ACCESS_SECRET | Platform secret manager in prod; api/.env in dev | Only the API process reads it | Yearly or on compromise |
JWT_REFRESH_SECRET | Same | Same | Yearly or on compromise |
MONGO_URI (with password) | Same | Only the API process reads it | On provisioning / incident |
| User passwords | Hashed (argon2id) in users.passwordHash | Never logged, never returned in responses, never compared via == | User-initiated; password-change endpoint |
| Refresh tokens | Hashed (SHA-256 via crypto.subtle.digest) in refresh_tokens.tokenHash | The plaintext only exists briefly on mint/rotate and in the client's chrome.storage.local | Rotate on every use |
| API keys (future) | Hashed in api_keys.keyHash | Plaintext shown once on creation | User-initiated; revoke via UI |
| Mongo Express basic-auth | ME_USER / ME_PASSWORD in root .env | Dev only — never deployed to prod | Dev-team rotation |
Rules
- Never commit secrets.
.envis git-ignored;.env.examplehas placeholders. - Never echo secrets. Exception: the API returns refresh tokens to the client once per refresh. That's the design.
- Hash refresh tokens server-side. We use SHA-256 (not bcrypt/argon2) because (a) entropy is already 256 bits from
crypto.randomBytes(32)— no brute force concern, and (b) lookup on login must be O(1), which a slow hash defeats. - Hash user passwords with argon2id. The entropy is user-chosen; the slow hash is the defense.
3. Input validation
Every controller input is a DTO validated by class-validator through the global ValidationPipe. The pipe is configured with:
whitelist: true— unknown properties are stripped silently... no wait, set viaforbidNonWhitelisted: true, so unknown properties return 400. Matches currentmain.ts:9-14.transform: true— strings become the declared type where valid (e.g.'5'→5on an@IsInt()field).
Never trust the client for:
- Pagination —
offset/limitare bounded (see features/pagination.md). - Status filter on
/content—status=pendingchecked against permissions; else 403. - Slugs — regex enforced via
@IsSlug(). - URLs —
@IsUrl()on submission; follow-up fetch-based validation (if any) runs server-side, not client-side. - Tag slugs submitted on content — verified to exist and be active; unknown tags reject the whole submission.
4. Output handling
The API returns JSON only. No HTML, no server-rendered templates. Therefore:
- XSS is the client's problem (sanitize embed HTML on render; see AgnosticUI.md §9).
- SQL injection is not applicable — we use Mongoose with parameterized queries.
- NoSQL injection: never pass unvalidated request bodies directly to
.find()or.$where.ValidationPipedefeats most of this; the remainder is code review.
5. Rate limiting
@nestjs/throttler applied globally with per-route overrides. Starting limits (revisit after observing real traffic):
| Route | Limit (unauthenticated) | Limit (authenticated) |
|---|---|---|
POST /auth/register | 5 / hour / IP | — |
POST /auth/login | 10 / hour / IP | — |
POST /auth/refresh | 60 / hour / IP | — |
POST /content/submit | — | 30 / hour / user |
GET /content | 120 / minute / IP | 600 / minute / user |
GET /catalog, GET /meta | no limit (cached upstream) | no limit |
Exceeded → 429 with Retry-After header.
Do not rate-limit health/ready endpoints (probes would break).
6. CORS
Strict allow-list from CORS_ALLOWED_ORIGINS (comma-separated). See AUTH.md §2 CORS.
- Development: include the unpacked extension's
chrome-extension://<dev-id>andhttp://localhost:*if a dev web viewer exists. - Production: the published extension's
chrome-extension://<web-store-id>only. - Never use
Access-Control-Allow-Origin: *combined with auth. Tokens are bearer-only — no credentials cookies — so in principle*is safe, but strict allow-list is the defensive default.
7. Dependency auditing
pnpm-lock.yamlcommitted.pnpm auditin CI (future). Fail build onhighorcritical.- Renovate or Dependabot opens PRs for updates. Human review before merge.
- Minimize the dependency surface — every new dependency is a supply-chain asset.
8. Account security UX
These features are not in scope for v1 but are called out so security decisions don't paint us into a corner:
- Email verification on registration — if/when added,
UsergetsemailVerifiedAt. Until then, self-registration treats the email as trusted-after-submit. - 2FA (TOTP) — fits cleanly into the Chrome extension (scan QR in the options page; prompt in login flow). Schema reservation:
User.twoFactor: { enabled: boolean; secret?: string; recoveryCodes?: string[] }. - Password-change revokes sessions — already documented in AUTH.md §6. Implement this in v1 so we don't have to retrofit revocation later.
9. Incident response
For specific scenarios (refresh-token compromise, rate-limit flood), see OPERATIONS.md §7.
General incident checklist
- Identify. What's the symptom? What's the blast radius? Who's affected?
- Contain. Revoke affected tokens. Block abusive IPs. Disable affected endpoints if needed.
- Communicate. Status page (TBD). If users are materially affected, email them.
- Remediate. Fix the underlying cause. Deploy.
- Postmortem. Within one week: what happened, timeline, what detection-caught-first vs could-have-caught-first, action items. Blameless.
Security-sensitive events that must be logged at error level
auth.refresh.reused— a refresh token that was already rotated was presented. Revoke the family; log the userId and the source IP.auth.password.bruteforce— an account hit the login rate limit N times.permissions.escalation_attempted— a user called an endpoint requiring a permission they lack. (Distinct from a normal 403; log only if repeated or from a user who should have known better.)
Alerts on these events (via whatever alerting is wired) are higher-priority than 5xx alarms.
10. Reporting a vulnerability
Placeholder — when we have a public domain: security@<domain>. Until then, the maintainer's direct channel.
Please give us 90 days before public disclosure.