Network & TLS
TLS cipher suite
Section titled “TLS cipher suite”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 hybridX25519MLKEM768, since that needs OpenSSL 3.5+, and thenginx:1.27-alpinebase image here is built against OpenSSL 3.3.3 as of this writing (check withdocker 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 theServerresponse header and from nginx-generated error pages, so an attacker fingerprinting for a version-specific nginx CVE getsnginx, notnginx/1.27.x. This doesn’t remove the header or token entirely, since that needs the third-partyheaders-more-nginx-module, which isn’t compiled into the stocknginx:1.27-alpinebase image used here.
Verify what actually negotiates against your own deployment:
openssl s_client -connect <your-domain>:443 -tls1_3 -brief </dev/nullconfig.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; preloadOne 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.
Why nginx uses host networking
Section titled “Why nginx uses host networking”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.
The X-Forwarded-For trust boundary
Section titled “The X-Forwarded-For trust boundary”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.