Files
webmail-client/docker/nginx-security.conf
T
lukasandClaude Opus 5.5 addb2232a1 Security hardening from the assessment; testing fixes
Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:08:06 +02:00

32 lines
2.3 KiB
Plaintext

# Security headers for every response of the front door — the app shell, the
# static files and the API alike.
#
# Included at server level by nginx-app.conf AND inside every location that
# adds a header of its own: nginx drops ALL inherited add_header lines in a
# location that has any add_header, which is how index.html once went out with
# only its Cache-Control (security report, finding 1). A new location with an
# add_header must include this file too.
#
# Content-Security-Policy, by directive:
# script-src 'self' the Vite bundles, plugin bundles (/api/plugins/…) and
# workers are all same-origin; no inline script, no eval
# (pdf.js is told not to try).
# style-src 'unsafe-inline': React and the plugins set style
# attributes, and message HTML carries its own <style>.
# img-src / font-src https:/http: because an email whose images the user
# chose to load fetches them from the sender's servers
# (the message frame is srcdoc, so it shares this policy).
# connect-src the API and the live-update WebSocket on this host
# ($http_host: with the port, for LAN access on WEB_PORT).
# frame-ancestors 'self', not 'none': printing an attachment loads it in
# a hidden frame of our own. No other site may frame us.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data: https:; media-src 'self' blob:; connect-src 'self' wss://$http_host ws://$http_host; worker-src 'self' blob:; frame-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
# Only on requests that reached the reverse proxy over HTTPS ($hsts is empty
# otherwise, and nginx sends no header with an empty value): plain-HTTP LAN
# access on WEB_PORT keeps working.
add_header Strict-Transport-Security $hsts always;