Skip to content

feat(nginx): TLS 1.3-only config, site examples and build hardening - #126

Merged
Stensel8 merged 14 commits into
mainfrom
nginx-tls13-hardening
Oct 12, 2026
Merged

Stensel8 merged 14 commits into
mainfrom
nginx-tls13-hardening

Conversation

@Stensel8

@Stensel8 Stensel8 commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

TLS

  • TLS 1.3 only, 256-bit suites only (AES-256-GCM, ChaCha20-Poly1305).
  • Key exchange: X25519MLKEM768 (post-quantum hybrid), X25519, P-256, P-384.
  • kTLS offload (tls kernel module loaded at boot; warns and falls back where unavailable).

Default server

  • Bare IP and unknown names get a self-signed localhost certificate, so neither the hostname nor a real site's certificate leaks.
  • Port 80 serves ACME HTTP-01 challenges from /var/www/acme and redirects everything else to HTTPS.
  • Server header removed.

Sites

  • conf.d/*.conf and stream.d/*.conf are included; $connection_upgrade map for websockets.
  • Examples (inactive until copied): reverse-proxy, static-site, tcp-passthrough, plus a shared snippets/proxy.conf. App-side proxy settings (Home Assistant, Jellyfin, Cockpit, …) are listed in the README.

Installer

  • Requires OpenSSL 3.5+ and says so before changing anything.
  • Checks for root before logging.
  • A rebuild keeps a running nginx serving and restarts it at the end.
  • Build flags: _FORTIFY_SOURCE=3, stack-clash protection, full RELRO.

Trade-offs

  • TLS 1.3-only excludes Windows 10/Server 2019 system TLS (Schannel), Android ≤ 8, IE and OpenSSL 1.0.2 clients. Modern browsers on any OS are fine.
  • Dropping TLS_AES_128_GCM_SHA256 (RFC 8446's mandatory suite) is for full marks in SSL Labs-style ratings; every client in testssl's simulation supports a 256-bit suite.

Testing

  • Debian 13 (OpenSSL 3.5.7): fresh install, rebuild over an existing install, kTLS active, Let's Encrypt wildcard via DNS-01, testssl A+ (96) before the 256-bit-only change.

Copilot AI balanced review requested due to automatic review settings October 11, 2026 22:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The rejection server does not cover QUIC, allowing unknown QUIC connections to reach the real virtual host.

1 open finding
What changed in this PR

Hardens fresh NGINX installations with TLS 1.3-only defaults, handshake rejection, upload sizing, worker tuning, and compiler protections.

Changes:

  • Enforces TLS 1.3 and adds a default TLS rejection server.
  • Adjusts headers, upload limits, and worker settings.
  • Enables compiler hardening flags.
File Description
nginx/​nginx_installer.sh Updates generated configuration and build flags.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread nginx/nginx_installer.sh Outdated
@Stensel8 Stensel8 self-assigned this Oct 11, 2026
…ardening

- ssl_ecdh_curve: keep prime256v1/secp384r1 after X25519. RFC 8446 makes
  secp256r1 the mandatory group; P-256-only clients (FIPS mode, embedded
  stacks) failed the handshake with the X25519-only list.
- Mark the reject block's quic listeners default_server explicitly. Without
  it the QUIC default depended on block order, and a quic server added above
  it served its certificate to unknown-SNI HTTP/3 clients again.
- Drop ssl_conf_command Options KTLS: it is a performance knob, not
  hardening, and without the tls kernel module it silently does nothing
  (nginx -t never rejects it, contrary to the comment).
- Build flags: -U before -D_FORTIFY_SOURCE=3 so Ubuntu's default level 3 is
  not lowered to 2; drop the redundant -fPIC; add -fstack-clash-protection
  and full RELRO via --with-ld-opt.
The generated config offers X25519MLKEM768, which OpenSSL only knows from
3.5 on; on older versions nginx -t failed at the very end of the install.
Check the system OpenSSL up front (before any package upgrade) and again
after the dependencies are installed, and stop with a clear "too old"
message instead. openssl is now an explicit apt/dnf dependency, since the
check and the self-signed certificate both need the CLI.
@Stensel8
Stensel8 force-pushed the nginx-tls13-hardening branch from e6ab7a0 to 42954b0 Compare October 12, 2026 11:21
@Stensel8 Stensel8 changed the title feat(nginx): TLS 1.3-only default config and build hardening feat(nginx): TLS 1.3-only config and build hardening (WIP, still testing) Oct 12, 2026
- The default vhost only listened to "localhost", so on a fresh install
  every browser visit by hostname hit the catch-all reject block
  (ERR_SSL_UNRECOGNIZED_NAME_ALERT). server_name and the self-signed
  certificate now also cover the short hostname and FQDN. Plain IP access
  sends no SNI and stays refused by design; the installer now says so and
  lists the URLs that work.
- kTLS: ssl_conf_command Options KTLS, and the installer loads the tls
  kernel module now and at boot (/etc/modules-load.d/nginx-ktls.conf).
  If the module cannot be loaded it only warns; OpenSSL then falls back to
  user-space TLS. remove cleans up the modules-load.d entry.
…by IP

Revert the hostname/FQDN in server_name and the certificate: anyone
reaching the server from outside could read the machine's name from the
certificate. Instead of refusing the handshake, the default server is now
the welcome vhost itself with the self-signed "localhost" certificate. The
bare IP address and every unknown name get that placeholder, so the page
is reachable by IP while scanners learn neither the hostname nor the
certificate of a real site added later (that site's server block, by
name, still gets its own certificate, over TCP and HTTP/3).
Lessons from a real deployment's config:

- nginx.conf now includes conf.d/*.conf and stream.d/*.conf and defines
  the $connection_upgrade map; without those, no site file or websocket
  proxy could work.
- snippets/proxy.conf holds the shared proxy settings (forwarded headers,
  websocket upgrade, compression off for proxied responses).
- Example sites as conf.d/*.conf.example (inactive until copied): Home
  Assistant, Jellyfin, the *arr apps, Seerr, qBittorrent and Cockpit, each
  with the app-side setting it needs (trusted proxies, known proxies,
  Cockpit origins, ...). stream.d/minecraft.conf.example shows a TCP
  passthrough.
- Port 80 serves ACME HTTP-01 challenges from /var/www/acme for every
  name (certbot --webroot) and redirects the rest to HTTPS.
@Stensel8 Stensel8 changed the title feat(nginx): TLS 1.3-only config and build hardening (WIP, still testing) feat(nginx): TLS 1.3-only config, proxy examples and build hardening (WIP, still testing) Oct 12, 2026
…root first

Found while rebuilding over an existing install on real hardware:

- Without sudo the script died with a bare 'Permission denied' on the log
  file in /var/log. install and remove now check for root before anything
  logs.
- The port check offered to stop the NGINX this script installed, taking
  every site down for the whole build. It now leaves that one running
  (recognised by its unit in /etc/systemd/system) and the install ends
  with a restart instead of a start, so the new binary takes over in one
  step.
Where a running nginx came from does not matter: it keeps serving during
the build and the restart at the end hands over to the new binary.
@Stensel8 Stensel8 changed the title feat(nginx): TLS 1.3-only config, proxy examples and build hardening (WIP, still testing) feat(nginx): TLS 1.3-only config, proxy examples and build hardening Oct 12, 2026
Six app-specific site files were more than a fresh install needs. Ship
three generic examples instead: reverse-proxy (HTTP or HTTPS upstream,
websockets), static-site and tcp-passthrough. The app-side settings that
made the per-app files useful (trusted proxies, Cockpit origins, ...) move
to one table in the README, and the README now mentions DNS-01 for
wildcard certificates.
@Stensel8 Stensel8 changed the title feat(nginx): TLS 1.3-only config, proxy examples and build hardening feat(nginx): TLS 1.3-only config, site examples and build hardening Oct 12, 2026
Drop TLS_AES_128_GCM_SHA256, leaving AES-256-GCM and ChaCha20-Poly1305.
testssl scored cipher strength 90 because of the 128-bit suite (A+ at
96); every mainstream client also speaks one of the 256-bit suites, so
this is full marks at no practical compatibility cost. QUIC Initial
packets use AES-128-GCM regardless, HTTP/3 keeps working.
@Stensel8
Stensel8 merged commit 5c32c1c into main Oct 12, 2026
10 checks passed
@Stensel8
Stensel8 deleted the nginx-tls13-hardening branch October 12, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants