| STRIDE | Threat | Mitigation |
|---|---|---|
| Spoofing | Stolen access JWT | Short-lived access token kept in memory only; bound to refresh familyId via sid claim |
| Spoofing | Stolen refresh token | HttpOnly cookie, SameSite=Lax, path /api/auth |
| Spoofing | Password offline crack | argon2id (memoryCost: 19456, timeCost: 2, parallelism: 1); transparent bcrypt→argon2id rehash on successful login. During migration, bcrypt-vs-argon2 timing difference vs the dummy pad is accepted. |
| Tampering | Refresh token reuse | Rotation + familyId revoke-on-reuse (1s grace so concurrent double-refresh does not kill the winner; losers get REFRESH_CONCURRENT without clearing the rotated cookie) |
| Tampering | CSRF on cookie endpoints | Data mutations are Bearer-only (no cookie auth); the two cookie-authenticated endpoints (/refresh, /logout) are protected by SameSite=Lax + an Origin allowlist (requireSameOrigin). Token-based CSRF middleware is intentionally not used — CodeQL's js/missing-csrf-middleware alert is dismissed with this rationale |
| Tampering | Access JWT after logout | In-memory revocation store: revokeFamily(sid) on per-device logout; revokeAllForUser (iat cutoff) on logout-everywhere; enforced in protect and socket handshake |
| Elevation | Cross-device logout blast | POST /api/auth/logout revokes only the presented refresh family; POST /api/auth/logout-all (Bearer) revokes all sessions |
| Elevation | Weak prod secrets | Fail-closed JWT_SECRET entropy check |
| Denial of service | Request floods | Per-IP rate limits on auth, refresh, logout, search, profile writes, reads (readLimiter), and media GETs (mediaLimiter). Behind nginx set TRUST_PROXY=1 so limits key on the real client IP (not the proxy). |
| Denial of service / ops | Process crash / data loss | docker-compose restart: unless-stopped; online SQLite+uploads backup script; graceful-shutdown 10s exit fuse |
| Tampering / XSS | SPA script injection | nginx CSP on SPA responses (script-src 'self'; style-src allows 'unsafe-inline' for React inline styles only) |
| STRIDE | Threat | Mitigation |
|---|---|---|
| Information disclosure | IDOR list/read DM | Shared assertCanListDm; GET /api/messages?receiverId= returns only the caller's thread with that peer (empty list is OK — not a cross-DM dump) |
| Tampering | Client-supplied mediaUrl |
Ignored; media only from upload pipeline |
| Elevation | Public/general message surface | Wave 1 is private DM only |
| Elevation / abuse | Unwanted DMs | Block list enforced at createMessage choke point (both directions; same DM_BLOCKED either way so the response does not reveal who blocked whom). Existing history remains readable; blocked users stay visible in search. |
| STRIDE | Threat | Mitigation |
|---|---|---|
| Spoofing | Unauthenticated socket | JWT required on handshake |
| Spoofing | Revoked access JWT reconnect | Handshake rejects revoked payloads with TOKEN_REVOKED; socket stores data.sid for per-device disconnect |
| Elevation | Arbitrary join_room |
Allowlist user:<self> and canonical own dm:a:b (sorted ids only) |
| Tampering | Client mediaUrl on send |
Ignored; text DM only via socket |
| Denial of service | Event flood | Per-user socket rate limit on message_send and join_room |
| Denial of service | Join phantom DM rooms | join_room requires allowlist + existing peer user id |
| STRIDE | Threat | Mitigation |
|---|---|---|
| Tampering | MIME confusion | Magic-bytes check (file-type); stored extension from detected type, not client originalname |
| Tampering | Image polyglot / metadata | Sharp re-encode to WebP (animated preserved); limitInputPixels + max dimension resize |
| Tampering | Non-image avatar/banner | Profile uploads allow images only; separate avatar/banner size caps |
| Information disclosure | Public static uploads | No express.static on /uploads; authenticated GET /api/media/* |
| Information disclosure | IDOR on DM attachments | media/ GETs require DM participant via assertCanAccessMediaAttachment; avatars/covers stay any-authenticated |
| Elevation | Path traversal on unlink/GET | Resolve under uploads/ only; replace avatar/banner unlinks old file via safeUnlinkFromServerRoot |
| Threat | Control | Code |
|---|---|---|
| IDOR list DM | assertCanListDm — ?receiverId= is caller's thread with peer only |
server/src/services/accessControl.ts, messageService.ts |
| IDOR media attachment GET | assertCanAccessMediaAttachment (DM participants); avatars/covers auth-only |
server/src/routes/mediaRoutes.ts, accessControl.ts |
Arbitrary join_room |
allowlist user: / dm: + peer exists + rate limit |
server/src/socket/index.ts, server/src/socket/rooms.ts |
Client mediaUrl |
ignored; upload-only | server/src/socket/index.ts, server/src/controllers/messageController.ts |
| Refresh theft/reuse | HttpOnly + rotation + family revoke | server/src/services/authService.ts, server/src/middleware/cookies.ts |
| Password storage | argon2id + legacy bcrypt verify/migrate | server/src/utils/passwords.ts, server/src/services/authService.ts |
| Access JWT after logout | sid-bound access tokens + in-memory revocation |
server/src/services/tokenRevocation.ts, server/src/middleware/auth.ts, server/src/socket/index.ts |
| Per-device / logout-everywhere | family-only logout vs logout-all |
server/src/services/authService.ts, server/src/controllers/authController.ts |
| Unwanted DMs | block list at createMessage |
server/src/services/blockService.ts, server/src/services/messageService.ts |
| Logout leaves socket open | per-family disconnect / disconnectSockets on user:<id> |
server/src/controllers/authController.ts, server/src/socket/index.ts |
| Upload orphan on 4xx | cleanupUploadsOnError unlinks multer files when status ≥400 |
server/src/middleware/cleanupUploadsOnError.ts |
| Upload type confusion | magic-bytes + sharp (limitInputPixels, resize, animated WebP); ext from file-type |
server/src/middleware/fileValidator.ts, server/src/services/uploadPipeline.ts |
| Path traversal unlink | safeUnlinkFromServerRoot on profile replace |
server/src/utils/uploads.ts, server/src/services/authService.ts |
| Public media scrape | authenticated media GET + DM ACL for media/ |
server/src/routes/mediaRoutes.ts |
| Weak JWT in prod | assertStrongSecretsOrThrow |
server/src/middleware/requireStrongSecrets.ts |
| Email PII leak | public DTO without email | server/src/utils/serializers.ts, server/src/services/dbShapes.ts |
| SPA XSS / mixed content | nginx CSP + companion headers | deploy/nginx/default.conf, Dockerfile.web |
| Rate-limit IP collapse behind proxy | TRUST_PROXY → app.set("trust proxy", 1) |
server/src/config/env.ts, server/src/app.ts |
| E2EE text content (honest-but-curious server / DB dump) | WebCrypto P-256 ECDH + HKDF + AES-GCM; TOFU + encrypt-lock | src/features/e2ee/lib/crypto.js, keyStore.js |
| E2EE public-key directory | Authenticated GET/PUT; reject JWK d; store minimal {kty,crv,x,y} |
server/src/routes/e2eeRoutes.ts, e2eeService.ts |
| E2EE key/plaintext not in Web Storage | Semgrep secure-chat.e2ee-no-webstorage |
.semgrep/rules/secure-chat-lab.yml |
| E2EE identity non-extractable | Semgrep secure-chat.e2ee-no-extractable-identity |
.semgrep/rules/secure-chat-lab.yml |
Scoped end-to-end encryption for text private messages only. See ADR docs/adr/0001-e2ee-scope.md.
Protects message content against: a database dump, stolen backups, and a passive/honest-but-curious server operator reading stored or in-transit content.
Does not protect against: an actively malicious server (it serves the JS and can ship key-exfiltrating code — mitigated but not eliminated by the wave-2 CSP), XSS on the client, device compromise, or metadata analysis (who talks to whom, when, how much — all still visible).
No forward secrecy and no post-compromise security: compromise of either party’s identity private key decrypts that pair’s entire E2EE history. Explicit trade-off vs a Double Ratchet (out of scope).
Single device / account per browser profile: the identity key is stored as identity:<userId> in IndexedDB (and TOFU pins as <ownerUserId>:<peerId>). A second account on the same profile gets its own key; a new device still generates a new key and peers get a key-change warning.
Media stays outside E2EE: the server-side magic-bytes + Sharp pipeline is a deliberate wave-1 control we keep. Media messages remain visibly “not encrypted” in the UI.
TOFU + encrypt-lock: first successful fetch of a peer’s public key is pinned in IndexedDB under the logged-in owner. Mismatch hard-blocks send until the user explicitly accepts the new key. If a peer was pinned and the server later returns 404 for their key, the client keeps the lock and does not silently fall back to plaintext (downgrade resistance).
- Signal-style X3DH / Double Ratchet, multi-device key sync, forward secrecy / post-compromise security
- WAF / CDN edge rules
- Email verification flows
- Distributed / multi-instance rate limiting or revocation stores (
express-rate-limitand access-token revocation remain in-memory / single-instance; useTRUST_PROXY=1behind the compose nginx so per-IP limits see the real client)
.github/workflows/ci.yml (SHA-pinned actions, top-level permissions: contents: read, per-ref concurrency):
- Blocking
npm audit --omit=dev --audit-level=highfor runtime deps (root + server); full audit stays informational - Server tests run with coverage thresholds (regression floor)
- Playwright e2e smoke exercises the real auth/DM/logout flow
- Docker job builds the server image from
Dockerfile(secrets/DBs excluded via.dockerignore) - CodeQL (
codeql.yml) analyzes JS/TS per PR and weekly; Semgrep uploads SARIF to code scanning
CI runs Semgrep on every push/PR (.github/workflows/ci.yml) with:
- Registry packs:
p/javascript,p/typescript,p/nodejs,p/expressjs,p/owasp-top-ten,p/secrets - Project rules:
.semgrep/rules/(upload static serving, clientmediaUrlpersistence, hard-coded JWT secrets, mediasendFilewithout DM ACL, E2EE no Web Storage / non-extractable identity) - Ignore policy:
.semgrepignore(deps, build output, generated Prisma client, uploads — security tests underserver/src/__tests__/securityremain in scope) - Gate: fails the job on ERROR severity findings
Locally (requires Semgrep CLI):
npm run semgrep # full report
npm run semgrep:ci # ERROR-only, non-zero exit on findingsPlease report vulnerabilities via GitHub Security Advisories for this repository, or contact the maintainer privately. Do not open public issues with exploit details until a fix is available.