From 314fa715fd9847240eed5334e4ccead5c4117f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20D=C3=A9siles?= <1536672+cdesiles@users.noreply.github.com> Date: Sat, 30 May 2026 17:06:10 +0200 Subject: [PATCH] fix(nginx): prevent cert leak on IPv6 / unknown SNI Two issues caused TLS to break on photos.carabosse.cloud over IPv6 (GrapheneOS + Immich app via Orange 5G NAT64): 1. Per-service vhosts only listened on IPv4 (listen 443 ssl). On IPv6, nginx fell back to the first vhost loaded alphabetically and served its certificate, breaking hostname verification on every other vhost. 2. /etc/letsencrypt/{live,archive} were 0700 root:root after certbot created them, so the nginx worker (user http on Arch) could not read the chained intermediates and served the leaf-only chain. Changes: - Add catch-all 00-default.conf default_server on :80 and :443 (v4+v6) with a self-signed cert and 'return 444'. ACME challenges still answered on :80. - Add IPv6 listeners ([::]:80 and [::]:443 ssl) to immich, gitea, ntfy, uptime_kuma vhosts and to the temporary ACME provisioning vhost. - Apply 0755 on /etc/letsencrypt/live and /etc/letsencrypt/archive on every run, not only at initial cert provisioning. --- roles/gitea/templates/nginx-vhost.conf.j2 | 2 + roles/immich/templates/nginx-vhost.conf.j2 | 2 + roles/nginx/README.md | 16 +++++++ roles/nginx/defaults/main.yml | 6 +++ roles/nginx/tasks/certbot.yml | 10 +++++ roles/nginx/tasks/main.yml | 43 +++++++++++++++++++ roles/nginx/templates/default-server.conf.j2 | 35 +++++++++++++++ roles/nginx/templates/vhost-http-acme.conf.j2 | 1 + roles/ntfy/templates/nginx-vhost.conf.j2 | 2 + .../uptime_kuma/templates/nginx-vhost.conf.j2 | 2 + 10 files changed, 119 insertions(+) create mode 100644 roles/nginx/templates/default-server.conf.j2 diff --git a/roles/gitea/templates/nginx-vhost.conf.j2 b/roles/gitea/templates/nginx-vhost.conf.j2 index 0fef30d..2f1a24d 100644 --- a/roles/gitea/templates/nginx-vhost.conf.j2 +++ b/roles/gitea/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ gitea_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ gitea_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot) diff --git a/roles/immich/templates/nginx-vhost.conf.j2 b/roles/immich/templates/nginx-vhost.conf.j2 index f9ad6ac..4aeb559 100644 --- a/roles/immich/templates/nginx-vhost.conf.j2 +++ b/roles/immich/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ immich_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ immich_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot) diff --git a/roles/nginx/README.md b/roles/nginx/README.md index 4d542e9..96f6a36 100644 --- a/roles/nginx/README.md +++ b/roles/nginx/README.md @@ -11,6 +11,22 @@ Installs and configures Nginx as a reverse proxy for web applications with modul - SSL/TLS configuration - **Native ACME/Let's Encrypt support** (Nginx 1.25.0+) - **Transparent proxy forwarding** (HTTP/HTTPS to other hosts) +- **Catch-all `default_server`** that rejects unknown SNI/Host with `444` + +## Catch-all default_server + +A `00-default.conf` vhost is deployed and marked `default_server` on both +ports 80 and 443. It uses a self-signed cert (`/etc/nginx/ssl/default.crt`) +and returns `444` (close connection) for any request whose SNI/Host does +not match an explicit vhost. ACME HTTP-01 challenges (`/.well-known/acme-challenge/`) +are still answered on port 80 so Certbot keeps working for new hostnames. + +Without this, clients hitting the server IP directly (or doing HTTP/2 +connection coalescing across vhosts sharing the same IP) would receive the +certificate of the first vhost loaded alphabetically, leaking that +hostname and breaking TLS verification on other vhosts. + +Disable with `nginx_default_server_enabled: false`. ## Service Integration Pattern diff --git a/roles/nginx/defaults/main.yml b/roles/nginx/defaults/main.yml index c41cddd..d9c63be 100644 --- a/roles/nginx/defaults/main.yml +++ b/roles/nginx/defaults/main.yml @@ -17,6 +17,12 @@ nginx_client_max_body_size: 100M # SSL configuration (volontarily omit TLSv1.2 here) nginx_ssl_protocols: TLSv1.3 +# Catch-all default_server (rejects unknown SNI / Host with 444). +# Prevents leaking the first-loaded vhost's cert to unrelated requests. +nginx_default_server_enabled: true +nginx_default_ssl_cert: /etc/nginx/ssl/default.crt +nginx_default_ssl_key: /etc/nginx/ssl/default.key + # Logging configuration # Backend: 'file' (traditional /var/log/nginx/*.log) or 'journald' (systemd journal) nginx_log_backend: journald diff --git a/roles/nginx/tasks/certbot.yml b/roles/nginx/tasks/certbot.yml index 815c2c0..e5849d2 100644 --- a/roles/nginx/tasks/certbot.yml +++ b/roles/nginx/tasks/certbot.yml @@ -29,6 +29,16 @@ path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem" register: certbot_cert_file +- name: Ensure letsencrypt directories are traversable by nginx + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - /etc/letsencrypt/live + - /etc/letsencrypt/archive + when: certbot_cert_file.stat.exists + - name: Provision certificate for {{ certbot_hostname }} when: not certbot_cert_file.stat.exists block: diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml index 2a8a18d..0fb0253 100644 --- a/roles/nginx/tasks/main.yml +++ b/roles/nginx/tasks/main.yml @@ -78,6 +78,49 @@ group: root mode: "0755" +- name: Configure catch-all default_server + when: nginx_default_server_enabled + block: + - name: Ensure nginx ssl directory exists + ansible.builtin.file: + path: "{{ nginx_default_ssl_cert | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Generate self-signed cert for default_server + ansible.builtin.command: + cmd: >- + openssl req -x509 -nodes -newkey rsa:2048 + -keyout {{ nginx_default_ssl_key }} + -out {{ nginx_default_ssl_cert }} + -days 3650 -subj "/CN=default" + creates: "{{ nginx_default_ssl_cert }}" + + - name: Restrict permissions on default_server key + ansible.builtin.file: + path: "{{ nginx_default_ssl_key }}" + owner: root + group: root + mode: "0600" + + - name: Deploy default_server vhost + ansible.builtin.template: + src: default-server.conf.j2 + dest: "{{ nginx_conf_dir }}/00-default.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx + +- name: Remove default_server vhost when disabled + ansible.builtin.file: + path: "{{ nginx_conf_dir }}/00-default.conf" + state: absent + when: not nginx_default_server_enabled + notify: Reload nginx + - name: Ensure Certbot webroot directory exists ansible.builtin.file: path: /var/www/certbot diff --git a/roles/nginx/templates/default-server.conf.j2 b/roles/nginx/templates/default-server.conf.j2 new file mode 100644 index 0000000..c1b1e09 --- /dev/null +++ b/roles/nginx/templates/default-server.conf.j2 @@ -0,0 +1,35 @@ +# Catch-all default_server vhosts +# Managed by Ansible - DO NOT EDIT MANUALLY +# +# Purpose: reject any request whose Host/SNI does not match an explicit +# server_name. Without this, the first vhost loaded alphabetically would +# leak its certificate to unrelated SNI requests (e.g. clients doing +# HTTP/2 connection coalescing or hitting the IP directly). +# +# `return 444` closes the connection without sending an HTTP response. + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Keep ACME HTTP-01 challenges working for any hostname + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 444; + } +} + +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + + ssl_certificate {{ nginx_default_ssl_cert }}; + ssl_certificate_key {{ nginx_default_ssl_key }}; + + return 444; +} diff --git a/roles/nginx/templates/vhost-http-acme.conf.j2 b/roles/nginx/templates/vhost-http-acme.conf.j2 index d32079a..6c1f5d8 100644 --- a/roles/nginx/templates/vhost-http-acme.conf.j2 +++ b/roles/nginx/templates/vhost-http-acme.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ certbot_hostname }}; location /.well-known/acme-challenge/ { diff --git a/roles/ntfy/templates/nginx-vhost.conf.j2 b/roles/ntfy/templates/nginx-vhost.conf.j2 index e5bef40..619ff27 100644 --- a/roles/ntfy/templates/nginx-vhost.conf.j2 +++ b/roles/ntfy/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ ntfy_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ ntfy_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot) diff --git a/roles/uptime_kuma/templates/nginx-vhost.conf.j2 b/roles/uptime_kuma/templates/nginx-vhost.conf.j2 index e0ace55..af395dc 100644 --- a/roles/uptime_kuma/templates/nginx-vhost.conf.j2 +++ b/roles/uptime_kuma/templates/nginx-vhost.conf.j2 @@ -3,6 +3,7 @@ server { listen 80; + listen [::]:80; server_name {{ uptime_kuma_nginx_hostname }}; # Certbot webroot for ACME challenges @@ -18,6 +19,7 @@ server { server { listen 443 ssl; + listen [::]:443 ssl; server_name {{ uptime_kuma_nginx_hostname }}; # Let's Encrypt certificates (managed by Certbot)