Skip to content

Network & TLS

nginx/templates/default.conf.template restricts TLS to a strong-only configuration, following the Mozilla “Intermediate” baseline (ssl-config.mozilla.org; re-check against that generator occasionally rather than treating this as permanently hand-tuned):

  • ssl_protocols TLSv1.2 TLSv1.3;, so no TLS 1.0 or 1.1.
  • Every listed TLS 1.2 cipher is ECDHE/DHE (forward secrecy) plus AEAD (GCM or ChaCha20-Poly1305). There’s no RC4, 3DES, export ciphers, NULL ciphers, or static RSA/DH key exchange, and nothing CBC-mode:
    ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
    ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:
    ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:
    DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA256
  • TLS 1.3’s own 3 ciphersuites (AES-256-GCM, AES-128-GCM, ChaCha20-Poly1305) are all AEAD by spec and aren’t separately configurable, so there’s no weak TLS 1.3 suite to exclude.
  • ssl_prefer_server_ciphers on;
  • ssl_ecdh_curve X25519:secp384r1;, with X25519 first since it’s fastest and strong, and secp384r1 as a fallback for older clients that don’t support X25519. This doesn’t yet use the post-quantum hybrid X25519MLKEM768, since that needs OpenSSL 3.5+, and the nginx:1.27-alpine base image here is built against OpenSSL 3.3.3 as of this writing (check with docker exec <nginx container> nginx -V). Swap the base image once one ships with a newer OpenSSL if that matters for your threat model.
  • Session tickets are disabled (ssl_session_tickets off;), not just left default. A single nginx instance has no ticket-key-rotation infrastructure, and a static or long-lived ticket key would quietly undermine the forward secrecy the cipher list above exists for. The non-ticket session cache (ssl_session_cache shared:TLS:10m;, ssl_session_timeout 1d;) still avoids a full handshake on reconnect.
  • OCSP stapling is on (ssl_stapling on; ssl_stapling_verify on;). It’s best-effort, silently skipped if the CA’s OCSP responder isn’t reachable (for example a fully air-gapped deployment), and it never blocks a handshake.
  • server_tokens off; drops the nginx version number from the Server response header and from nginx-generated error pages, so an attacker fingerprinting for a version-specific nginx CVE gets nginx, not nginx/1.27.x. This doesn’t remove the header or token entirely, since that needs the third-party headers-more-nginx-module, which isn’t compiled into the stock nginx:1.27-alpine base image used here.

Verify what actually negotiates against your own deployment:

Terminal window
openssl s_client -connect <your-domain>:443 -tls1_3 -brief </dev/null

config.settings.prod’s SECURE_HSTS_* settings make Django emit Strict-Transport-Security, but nginx hides that Django-emitted copy (proxy_hide_header Strict-Transport-Security;) and adds its own instead. That way the client only ever sees one copy, with nginx as the single source of truth:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

One year (the standard minimum for eligibility on browser HSTS preload lists), including subdomains, applied to every response from the TLS server block, with static assets included too.

Cache-Control: no-store on dynamic responses

Section titled “Cache-Control: no-store on dynamic responses”

The main location / block strips whatever Cache-Control Django set and forces Cache-Control: no-store on every response, so findings, engagement detail, decrypted content, and exported reports never get retained by a browser, intermediate proxy, or back-forward cache. Static assets (location /static/, served by WhiteNoise with long-lived immutable caching since filenames are content-hashed) are deliberately not covered by this, since caching those aggressively is both safe and the entire point of content-hashed filenames.

nginx’s Docker Compose service runs with network_mode: host rather than a ports: - "80:80" mapping. See the full rationale in Docker Compose deep dive. In short, a published-port mapping is relayed through a userspace docker-proxy process that substitutes its own address as the connection source, so without host networking, nginx would see a Docker-internal address instead of the real client IP for every request. That silently breaks both login-lockout IP tracking and audit log IP recording at the source.

apps.accounts.security.client_ip and apps.audit.middleware both read the client IP as request.META["HTTP_X_FORWARDED_FOR"].split(",")[0], trusting that nginx has already normalized this header. nginx’s config sets it explicitly, always overwriting rather than appending:

proxy_set_header X-Forwarded-For $remote_addr;

Deliberately $remote_addr (nginx’s own view of the real TCP peer), not $proxy_add_x_forwarded_for (which would append onto whatever X-Forwarded-For the client itself already sent). This is a single reverse-proxy hop with no further trusted proxy in front of nginx, so there’s no legitimate upstream chain to append to. Appending would let an external client spoof the first hop of that header and both bypass IP-based login lockout and pollute the audit log with a fabricated address.

Tailscale Funnel changes the trust boundary slightly

Section titled “Tailscale Funnel changes the trust boundary slightly”

If you expose RedScribe beyond your tailnet with Tailscale Funnel, Funnel forwards over loopback and prepends a PROXY protocol preamble (--proxy-protocol=2). nginx’s config adds a second, more specific listen 127.0.0.1:443 ssl proxy_protocol; socket (nginx always picks the most specific matching listener, so this never affects the primary tailnet-facing one) with set_real_ip_from 127.0.0.1; and real_ip_header proxy_protocol;, trusting the PROXY-protocol-conveyed IP only for connections that land specifically on the loopback listener. This is a deliberately narrow trust boundary: only a process already running on the host could ever reach that listener at all, the same trust level host-level access already implies everywhere else in this stack.