Skip to content

Security: kotru21/webchat

SECURITY.md

Security Policy — Secure Chat Lab

Threat model (STRIDE)

Authentication & sessions

STRIDEThreatMitigation
SpoofingStolen access JWTShort-lived access token kept in memory only; bound to refresh familyId via sid claim
SpoofingStolen refresh tokenHttpOnly cookie, SameSite=Lax, path /api/auth
SpoofingPassword offline crackargon2id (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.
TamperingRefresh token reuseRotation + familyId revoke-on-reuse (1s grace so concurrent double-refresh does not kill the winner; losers get REFRESH_CONCURRENT without clearing the rotated cookie)
TamperingCSRF on cookie endpointsData 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
TamperingAccess JWT after logoutIn-memory revocation store: revokeFamily(sid) on per-device logout; revokeAllForUser (iat cutoff) on logout-everywhere; enforced in protect and socket handshake
ElevationCross-device logout blastPOST /api/auth/logout revokes only the presented refresh family; POST /api/auth/logout-all (Bearer) revokes all sessions
ElevationWeak prod secretsFail-closed JWT_SECRET entropy check
Denial of serviceRequest floodsPer-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 / opsProcess crash / data lossdocker-composerestart: unless-stopped; online SQLite+uploads backup script; graceful-shutdown 10s exit fuse
Tampering / XSSSPA script injectionnginx CSP on SPA responses (script-src 'self'; style-src allows 'unsafe-inline' for React inline styles only)

Messages (REST)

STRIDEThreatMitigation
Information disclosureIDOR list/read DMShared assertCanListDm; GET /api/messages?receiverId= returns only the caller's thread with that peer (empty list is OK — not a cross-DM dump)
TamperingClient-supplied mediaUrlIgnored; media only from upload pipeline
ElevationPublic/general message surfaceWave 1 is private DM only
Elevation / abuseUnwanted DMsBlock 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.

Socket.IO

STRIDEThreatMitigation
SpoofingUnauthenticated socketJWT required on handshake
SpoofingRevoked access JWT reconnectHandshake rejects revoked payloads with TOKEN_REVOKED; socket stores data.sid for per-device disconnect
ElevationArbitrary join_roomAllowlist user:<self> and canonical own dm:a:b (sorted ids only)
TamperingClient mediaUrl on sendIgnored; text DM only via socket
Denial of serviceEvent floodPer-user socket rate limit on message_send and join_room
Denial of serviceJoin phantom DM roomsjoin_room requires allowlist + existing peer user id

Uploads

STRIDEThreatMitigation
TamperingMIME confusionMagic-bytes check (file-type); stored extension from detected type, not client originalname
TamperingImage polyglot / metadataSharp re-encode to WebP (animated preserved); limitInputPixels + max dimension resize
TamperingNon-image avatar/bannerProfile uploads allow images only; separate avatar/banner size caps
Information disclosurePublic static uploadsNo express.static on /uploads; authenticated GET /api/media/*
Information disclosureIDOR on DM attachmentsmedia/ GETs require DM participant via assertCanAccessMediaAttachment; avatars/covers stay any-authenticated
ElevationPath traversal on unlink/GETResolve under uploads/ only; replace avatar/banner unlinks old file via safeUnlinkFromServerRoot

Controls mapping

ThreatControlCode
IDOR list DMassertCanListDm?receiverId= is caller's thread with peer onlyserver/src/services/accessControl.ts, messageService.ts
IDOR media attachment GETassertCanAccessMediaAttachment (DM participants); avatars/covers auth-onlyserver/src/routes/mediaRoutes.ts, accessControl.ts
Arbitrary join_roomallowlist user: / dm: + peer exists + rate limitserver/src/socket/index.ts, server/src/socket/rooms.ts
Client mediaUrlignored; upload-onlyserver/src/socket/index.ts, server/src/controllers/messageController.ts
Refresh theft/reuseHttpOnly + rotation + family revokeserver/src/services/authService.ts, server/src/middleware/cookies.ts
Password storageargon2id + legacy bcrypt verify/migrateserver/src/utils/passwords.ts, server/src/services/authService.ts
Access JWT after logoutsid-bound access tokens + in-memory revocationserver/src/services/tokenRevocation.ts, server/src/middleware/auth.ts, server/src/socket/index.ts
Per-device / logout-everywherefamily-only logout vs logout-allserver/src/services/authService.ts, server/src/controllers/authController.ts
Unwanted DMsblock list at createMessageserver/src/services/blockService.ts, server/src/services/messageService.ts
Logout leaves socket openper-family disconnect / disconnectSockets on user:<id>server/src/controllers/authController.ts, server/src/socket/index.ts
Upload orphan on 4xxcleanupUploadsOnError unlinks multer files when status ≥400server/src/middleware/cleanupUploadsOnError.ts
Upload type confusionmagic-bytes + sharp (limitInputPixels, resize, animated WebP); ext from file-typeserver/src/middleware/fileValidator.ts, server/src/services/uploadPipeline.ts
Path traversal unlinksafeUnlinkFromServerRoot on profile replaceserver/src/utils/uploads.ts, server/src/services/authService.ts
Public media scrapeauthenticated media GET + DM ACL for media/server/src/routes/mediaRoutes.ts
Weak JWT in prodassertStrongSecretsOrThrowserver/src/middleware/requireStrongSecrets.ts
Email PII leakpublic DTO without emailserver/src/utils/serializers.ts, server/src/services/dbShapes.ts
SPA XSS / mixed contentnginx CSP + companion headersdeploy/nginx/default.conf, Dockerfile.web
Rate-limit IP collapse behind proxyTRUST_PROXYapp.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-locksrc/features/e2ee/lib/crypto.js, keyStore.js
E2EE public-key directoryAuthenticated GET/PUT; reject JWK d; store minimal {kty,crv,x,y}server/src/routes/e2eeRoutes.ts, e2eeService.ts
E2EE key/plaintext not in Web StorageSemgrep secure-chat.e2ee-no-webstorage.semgrep/rules/secure-chat-lab.yml
E2EE identity non-extractableSemgrep secure-chat.e2ee-no-extractable-identity.semgrep/rules/secure-chat-lab.yml

E2EE (text DMs)

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).

Out of scope

  • 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-limit and access-token revocation remain in-memory / single-instance; use TRUST_PROXY=1 behind the compose nginx so per-IP limits see the real client)

CI security gates

.github/workflows/ci.yml (SHA-pinned actions, top-level permissions: contents: read, per-ref concurrency):

  • Blocking npm audit --omit=dev --audit-level=high for 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

Static analysis (Semgrep)

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, client mediaUrl persistence, hard-coded JWT secrets, media sendFile without DM ACL, E2EE no Web Storage / non-extractable identity)
  • Ignore policy: .semgrepignore (deps, build output, generated Prisma client, uploads — security tests under server/src/__tests__/security remain 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 findings

Reporting

Please 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.

There aren't any published security advisories