2 Security
Emily edited this page 2026-11-04 23:27:23 +01:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Security

Threat model

The backend is a small optional account service. It protects:

  • Account credentials and sessions.
  • E-mail addresses and verification state.
  • Leaderboard scores.
  • Chess match integrity.
  • Operator access to the management panel.

It does not receive gameplay state, saved games, device identifiers or analytics.

Password storage

Passwords are hashed with bcrypt after a SHA-256 pre-hash:

bcrypt-sha256$<bcrypt hash of base64(sha256(password))>

Why:

  • bcrypt only consumes the first 72 bytes; pre-hashing lets the full password contribute.
  • SHA-256 output is base64-encoded to avoid NUL bytes in the bcrypt input.
  • The bcrypt-sha256$ prefix records the scheme.
  • Legacy bcrypt hashes still verify and are upgraded after the next successful sign-in.

Passwords must be 8–128 characters. They are never logged or returned.

Sessions

  • HS256 JWTs signed with JWT_SECRET (minimum 32 characters).
  • Issuer universal-gaming, audience universal-gaming-app.
  • Algorithm is pinned to HS256 during verification.
  • Tokens carry sub, username, ver and exp.
  • auth_version is checked against the database on every authenticated request.
  • A password reset bumps auth_version, invalidating all older tokens.
  • Deleting an account invalidates its tokens because the account no longer exists.
  • Default lifetime is 30 days; JWT_TTL_SECONDS controls it.

Rotating JWT_SECRET signs everyone out immediately.

E-mail tokens

  • Verification and reset tokens are 32 random bytes encoded as base64url.
  • Only the SHA-256 hash is stored in the database.
  • Verification links expire after 48 hours.
  • Password-reset links expire after 30 minutes by default.
  • Reset tokens are single-use and cleared when the password changes.
  • Changing an e-mail clears pending verification and reset state.

Account enumeration

  • Login failures always return the same message.
  • POST /api/v1/auth/forgot-password always returns 202 { "sent": true }, even for unknown or unconfirmed addresses.
  • Password reset is only sent to accounts with an assigned and confirmed address.
  • check-username is intentionally public, rate limited, and only reports availability.

Rate limiting

Limiter Window Limit
Auth (register, login, reset-password) 15 min 30
Username check 1 min 60
E-mail (forgot-password, resend, verify) 15 min 8
Chess 1 min 120
Leaderboard read 1 min 120
Leaderboard write 1 min 30

Rate limits depend on the real client IP. Correct TRUSTED_PROXIES configuration is required; otherwise every request may appear to come from the proxy and share one bucket.

CORS

CORS_ORIGINS is a comma-separated allow-list. When it contains *, every origin is allowed. Production deployments should list the exact web origin:

CORS_ORIGINS=https://games.example.com

Allowed methods: GET, POST, PATCH, DELETE, OPTIONS. Allowed headers: Content-Type, Authorization.

HTTP hardening

  • helmet() sets standard security headers.
  • x-powered-by is disabled.
  • JSON bodies are limited to 16 KB; larger requests return 413.
  • Request logs strip query strings and never include bodies, tokens, passwords or e-mail addresses.
  • Failed logins log only the client IP.
  • The verification page sets noindex,nofollow.
  • The management panel sets Cache-Control: no-store.

Trusted proxies

TRUSTED_PROXIES accepts:

  • IP addresses.
  • CIDR ranges.
  • loopback, linklocal, uniquelocal presets.

Invalid entries fail startup. When set, it takes precedence over the legacy TRUST_PROXY.

Do not use TRUST_PROXY=true on a public server: a client could spoof X-Forwarded-For and bypass IP-based rate limits.

Management panel

  • Read-only HTML.
  • HTTP Basic authentication.
  • Disabled entirely when MANAGEMENT_PASSWORD is empty.
  • Username defaults to admin.
  • Store the password in a root-only file, never in Git.
  • Restrict /management at the reverse proxy or with network policy where possible.
  • It exposes aggregate counts and recent activity, not password hashes or tokens.

SMTP security

  • Implicit TLS on port 465.
  • STARTTLS on ports 25, 587 and 2525.
  • SMTP_SECURE=true on a STARTTLS port is auto-corrected with a warning.
  • TLS server name is set to SMTP_HOST.
  • Connection, greeting and socket timeouts are bounded.
  • SMTP credentials live only in .env.

A TLS mode mismatch produces SSL routines:tls_validate_record_header:wrong version number. Fix the port/mode combination rather than disabling TLS.

Chess integrity

  • Every move is replayed from the start with chess.js.
  • Turn order is checked against the stored match.
  • Illegal moves return 400.
  • Stale writes return 409 using an optimistic version field.
  • Results (checkmate, stalemate, draws) are computed server-side.
  • The client is never trusted for legality or outcome.

Leaderboard integrity

  • Game IDs are restricted to the ten ranked games.
  • Mode, seconds, moves and detail are validated and bounded.
  • One row per user, game and mode; worse scores do not replace better ones.
  • Only public fields are returned: username, time, moves, detail and date. E-mail addresses and UUIDs are not exposed.

Scores are client-reported and cannot be proven without server-side gameplay. Treat the board as a friendly competition, not an anti-cheat system.

Secrets

Never commit:

  • .env
  • JWT_SECRET
  • PostgreSQL passwords
  • SMTP credentials
  • MANAGEMENT_PASSWORD
  • Database dumps
  • Release artifacts containing credentials

.env.example contains placeholders only.

Dependency security

npm audit --omit=dev

At release 1.4.0, the production dependency audit reports zero vulnerabilities. Re-run it before every release and update dependencies deliberately.