Skip to content

Security: mplsllc/cms

Security

docs/security.md

Security

MPLS CMS was built for a journalist covering politically sensitive topics. Every security feature exists because the threat model demanded it — not as an afterthought or optional plugin.

What ships by default

Authentication

  • Redis-backed sessions — 32-byte crypto/rand tokens stored in Redis with 24-hour sliding TTL. No JWTs, no client-side token storage.
  • bcrypt cost 12 — password hashing with a cost factor that takes ~250ms per hash, making brute force impractical.
  • TOTP 2FA — optional per-user two-factor authentication using standard TOTP (Google Authenticator, Authy, etc.). Secrets encrypted at rest with AES-256-GCM.
  • HttpOnly + Secure + SameSite=Lax cookies — session cookies are inaccessible to JavaScript and only sent over HTTPS.
  • Session management — users can view active sessions, revoke individual sessions, or revoke all sessions.

Authorization

  • Role-based access control — three roles: admin, editor, contributor. Contributors can only edit their own posts. Editors can publish. Admins manage users and settings.
  • Per-route middlewareRequireRole("admin") applied at the route group level, not checked inline in handlers.

CSRF

  • Double-submit cookie — CSRF token set as a cookie on GET requests and validated against a form field or X-CSRF-Token header on POST/PUT/DELETE.
  • SameSite=Strict on cookie — prevents cross-origin cookie sending.
  • Token stability — the CSRF token is reused for the cookie's lifetime (1 hour), not rotated on every GET. This prevents token invalidation when fetch-based requests (like media picker loading) trigger a GET that would otherwise rotate the cookie out from under the form.

Rate limiting

  • Redis sliding window — configurable per-route rate limits. Default limits:
    • Login: 5 attempts per 15 minutes per IP
    • Contact form: 3 submissions per hour per IP
    • Newsletter subscribe: 5 per hour per IP
    • Search: 30 per minute per IP
    • API: 60 per minute per IP

Content Security Policy

  • Strict CSP built from config slices — no wildcard * domains.
  • Separate config for img-src, frame-src, script-src, style-src, font-src, connect-src.
  • CSP violations are reported but not blocked in development mode.

HTTP security headers

Every response includes:

  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-XSS-Protection: 0 (modern browsers use CSP instead)
  • Strict-Transport-Security: max-age=31536000; includeSubDomains; preload (production only)

HTML sanitization

  • bluemonday — all rich text editor output is sanitized server-side before storage.
  • Custom policy allows YouTube/Twitch iframe embeds from whitelisted domains only.
  • No client-side sanitization — the server is the single source of truth.

File uploads

  • MIME detection by content — file type is detected from the first 512 bytes, not the file extension.
  • UUID filenames — uploaded files are renamed to {year}/{month}/{uuid}.{ext}, preventing path traversal and filename collisions.
  • Configurable size limitMAX_UPLOAD_BYTES defaults to 10MB.
  • R2/S3 or local disk — uploads go to Cloudflare R2 (S3-compatible) or local disk, configurable per environment.

Anti-spam

  • ALTCHA proof-of-work — client-side computational challenge on forms (contact, comments). Not a CAPTCHA — no image puzzles. The client solves a hash challenge that proves it spent CPU time.
  • Honeypot fields — hidden form fields that bots fill in. Submissions with honeypot data are silently accepted (no error feedback to the bot).
  • Content checks — basic spam pattern detection (all-caps, URL density, known spam phrases).

Audit logging

  • Every admin action is logged with user ID, action, target, details JSON, IP address, and timestamp.
  • Audit entries are published to the SSE hub for real-time monitoring.

Password reset

  • Hashed tokens — reset tokens are hashed before storage (bcrypt). The plaintext token is sent to the user's email; the hash is stored in the database.
  • 1-hour expiry — tokens expire after one hour.
  • Anti-enumeration — the same response is returned whether the email exists or not.

Site gate

For pre-launch or staging environments, the site gate requires a password to access any page:

SITE_GATE_PASSWORD=preview123

When set, all visitors see a password prompt before accessing the site. Admin routes and health checks bypass the gate.

Secrets management

The framework reads all secrets from environment variables. In production, use a secrets manager like Infisical, Vault, or AWS Secrets Manager to inject them.

Required secrets:

  • SESSION_SECRET — 32+ bytes, used to derive session storage keys
  • CSRF_SECRET — 32+ bytes (currently used for the CSRF cookie domain)
  • TOTP_ENCRYPTION_KEY — exactly 32 bytes (hex-encoded), used for AES-256-GCM encryption of TOTP secrets

Generate with:

openssl rand -hex 32

What the framework does NOT do

  • No WAF — use Cloudflare, AWS WAF, or nginx rate limiting for network-level protection.
  • No DDoS protection — use a CDN or reverse proxy.
  • No automatic dependency updates — use Dependabot or Renovate.
  • No penetration testing — hire a professional.
  • No encryption at rest — use PostgreSQL's built-in encryption or full-disk encryption at the OS level.

There aren't any published security advisories