Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Improve handling of unknown hosts and missing certs #2186

Merged
merged 5 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,17 +347,41 @@ Note that the `Mozilla-Old` policy should use a 1024 bits DH key for compatibili

The default behavior for the proxy when port 80 and 443 are exposed is as follows:

* If a container has a usable cert, port 80 will redirect to 443 for that container so that HTTPS is always preferred when available.
* If the container does not have a usable cert, a 503 will be returned.

Note that in the latter case, a browser may get an connection error as no certificate is available to establish a connection. A self-signed or generic cert named `default.crt` and `default.key` will allow a client browser to make a SSL connection (likely w/ a warning) and subsequently receive a 500.
* If a virtual host has a usable cert, port 80 will redirect to 443 for that virtual host so that HTTPS is always preferred when available.
* If the virtual host does not have a usable cert, but `default.crt` and `default.key` exist, those will be used as the virtual host's certificate and the client browser will receive a 500 error.
* If the virtual host does not have a usable cert, and `default.crt` and `default.key` do not exist, TLS negotiation will fail (see [Missing Certificate](#missing-certificate) below).

To serve traffic in both SSL and non-SSL modes without redirecting to SSL, you can include the environment variable `HTTPS_METHOD=noredirect` (the default is `HTTPS_METHOD=redirect`). You can also disable the non-SSL site entirely with `HTTPS_METHOD=nohttp`, or disable the HTTPS site with `HTTPS_METHOD=nohttps`. `HTTPS_METHOD` can be specified on each container for which you want to override the default behavior or on the proxy container to set it globally. If `HTTPS_METHOD=noredirect` is used, Strict Transport Security (HSTS) is disabled to prevent HTTPS users from being redirected by the client. If you cannot get to the HTTP site after changing this setting, your browser has probably cached the HSTS policy and is automatically redirecting you back to HTTPS. You will need to clear your browser's HSTS cache or use an incognito window / different browser.

By default, [HTTP Strict Transport Security (HSTS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) is enabled with `max-age=31536000` for HTTPS sites. You can disable HSTS with the environment variable `HSTS=off` or use a custom HSTS configuration like `HSTS=max-age=31536000; includeSubDomains; preload`.

*WARNING*: HSTS will force your users to visit the HTTPS version of your site for the `max-age` time - even if they type in `http://` manually. The only way to get to an HTTP site after receiving an HSTS response is to clear your browser's HSTS cache.

#### Missing Certificate

If HTTPS is enabled for a virtual host but its certificate is missing, nginx-proxy will configure nginx to use the default certificate (`default.crt` with `default.key`) and return a 500 error.

If the default certificate is also missing, nginx-proxy will configure nginx to accept HTTPS connections but fail the TLS negotiation. Client browsers will render a TLS error page. As of March 2023, web browsers display the following error messages:

* Chrome:

> This site can't provide a secure connection
>
> example.test sent an invalid response.
>
> Try running Connectivity Diagnostics.
>
> `ERR_SSL_PROTOCOL_ERROR`

* Firefox:

> Secure Connection Failed
>
> An error occurred during a connection to example.test.
> Peer reports it experienced an internal error.
>
> Error code: `SSL_ERROR_INTERNAL_ERROR_ALERT` "TLS error".

### Basic Authentication Support

In order to be able to secure your virtual host, you have to create a file named as its equivalent VIRTUAL_HOST variable on directory
Expand Down
205 changes: 126 additions & 79 deletions nginx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
{{- $_ := set $globals "Env" $.Env }}
{{- $_ := set $globals "Docker" $.Docker }}
{{- $_ := set $globals "CurrentContainer" (where $globals.containers "ID" $globals.Docker.CurrentContainerID | first) }}
{{- $_ := set $globals "default_cert_ok" (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }}
{{- $_ := set $globals "external_http_port" (coalesce $globals.Env.HTTP_PORT "80") }}
{{- $_ := set $globals "external_https_port" (coalesce $globals.Env.HTTPS_PORT "443") }}
{{- $_ := set $globals "sha1_upstream_name" (parseBool (coalesce $globals.Env.SHA1_UPSTREAM_NAME "false")) }}
Expand All @@ -19,6 +20,7 @@
{{- $_ := set $globals "access_log" (or (and (not $globals.Env.DISABLE_ACCESS_LOGS) "access_log /var/log/nginx/access.log vhost;") "") }}
{{- $_ := set $globals "enable_ipv6" (parseBool (coalesce $globals.Env.ENABLE_IPV6 "false")) }}
{{- $_ := set $globals "ssl_policy" (or ($globals.Env.SSL_POLICY) "Mozilla-Intermediate") }}
{{- $_ := set $globals "vhosts" (dict) }}
{{- $_ := set $globals "networks" (dict) }}
# Networks available to the container running docker-gen (which are assumed to
# match the networks available to the container running nginx):
Expand Down Expand Up @@ -345,36 +347,103 @@ proxy_set_header X-Original-URI $request_uri;
proxy_set_header Proxy "";
{{- end }}

{{- /*
* Precompute some information about each vhost. This is done early because
* the creation of fallback servers depends on DEFAULT_HOST, HTTPS_METHOD,
* and whether there are any missing certs.
*/}}
{{- range $vhost, $containers := groupByMulti $globals.containers "Env.VIRTUAL_HOST" "," }}
{{- $vhost := trim $vhost }}
{{- if not $vhost }}
{{- /* Ignore containers with VIRTUAL_HOST set to the empty string. */}}
{{- continue }}
{{- end }}
{{- $certName := first (groupByKeys $containers "Env.CERT_NAME") }}
{{- $vhostCert := closest (dir "/etc/nginx/certs") (printf "%s.crt" $vhost) }}
{{- $vhostCert = trimSuffix ".crt" $vhostCert }}
{{- $vhostCert = trimSuffix ".key" $vhostCert }}
{{- $cert := or $certName $vhostCert }}
{{- $cert_ok := and (ne $cert "") (exists (printf "/etc/nginx/certs/%s.crt" $cert)) (exists (printf "/etc/nginx/certs/%s.key" $cert)) }}
{{- $default := eq $globals.Env.DEFAULT_HOST $vhost }}
{{- $https_method := or (first (groupByKeys $containers "Env.HTTPS_METHOD")) $globals.Env.HTTPS_METHOD "redirect" }}
{{- $_ := set $globals.vhosts $vhost (dict "cert" $cert "cert_ok" $cert_ok "containers" $containers "default" $default "https_method" $https_method) }}
{{- end }}

{{- /*
* If needed, create a catch-all fallback server to send an error code to
* clients that request something from an unknown vhost.
*/}}
{{- block "fallback_server" $globals }}
{{- $globals := . }}
{{- $http_exists := false }}
{{- $https_exists := false }}
{{- $default_http_exists := false }}
{{- $default_https_exists := false }}
{{- range $vhost := $globals.vhosts }}
{{- $http := or (ne $vhost.https_method "nohttp") (not $vhost.cert_ok) }}
{{- $https := ne $vhost.https_method "nohttps" }}
{{- $http_exists = or $http_exists $http }}
{{- $https_exists = or $https_exists $https }}
{{- $default_http_exists = or $default_http_exists (and $http $vhost.default) }}
{{- $default_https_exists = or $default_https_exists (and $https $vhost.default) }}
{{- end }}
{{- $fallback_http := and $http_exists (not $default_http_exists) }}
{{- $fallback_https := and $https_exists (not $default_https_exists) }}
{{- /*
* If there are no vhosts at all, create fallbacks for both plain http
* and https so that clients get something more useful than a connection
* refused error.
*/}}
{{- if and (not $http_exists) (not $https_exists) }}
{{- $fallback_http = true }}
{{- $fallback_https = true }}
{{- end }}
{{- if or $fallback_http $fallback_https }}
server {
server_name _; # This is just an invalid value which will never trigger on a real hostname.
server_tokens off;
listen {{ $globals.external_http_port }};
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_http_port }};
{{- end }}
{{- if $fallback_http }}
listen {{ $globals.external_http_port }} default_server;
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_http_port }} default_server;
{{- end }}
{{- end }}
{{- if $fallback_https }}
listen {{ $globals.external_https_port }} ssl http2 default_server;
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_https_port }} ssl http2 default_server;
{{- end }}
{{- end }}
{{ $globals.access_log }}
return 503;

{{- if (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }}
listen {{ $globals.external_https_port }} ssl http2;
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_https_port }} ssl http2;
{{- end }}

{{- if $globals.default_cert_ok }}
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_certificate /etc/nginx/certs/default.crt;
ssl_certificate_key /etc/nginx/certs/default.key;
{{- end }}
{{- else }}
# No default.crt certificate found for this vhost, so force nginx to emit a
# TLS error if the client connects via https.
{{- /* See the comment in the main `server` directive for rationale. */}}
ssl_ciphers aNULL;
set $empty "";
ssl_certificate data:$empty;
ssl_certificate_key data:$empty;
if ($https) {
return 444;
}
{{- end }}
return 503;
}
{{- end }}
{{- end }}

{{- range $host, $containers := groupByMulti $globals.containers "Env.VIRTUAL_HOST" "," }}
{{- range $host, $vhost := $globals.vhosts }}
{{- $cert := $vhost.cert }}
{{- $cert_ok := $vhost.cert_ok }}
{{- $containers := $vhost.containers }}
{{- $default_server := when $vhost.default "default_server" "" }}
{{- $https_method := $vhost.https_method }}

{{- $host := trim $host }}
{{- if not $host }}
{{- /* Ignore containers with VIRTUAL_HOST set to the empty string. */}}
{{- continue }}
{{- end }}
{{- $is_regexp := hasPrefix "~" $host }}
{{- $upstream_name := when (or $is_regexp $globals.sha1_upstream_name) (sha1 $host) $host }}

Expand All @@ -394,22 +463,12 @@ server {
{{ template "upstream" (dict "globals" $globals "Upstream" $upstream "Containers" $containers) }}
{{- end }}

{{- $default_host := or ($globals.Env.DEFAULT_HOST) "" }}
{{- $default_server := index (dict $host "" $default_host "default_server") $host }}

{{- /*
* Get the SERVER_TOKENS defined by containers w/ the same vhost,
* falling back to "".
*/}}
{{- $server_tokens := trim (or (first (groupByKeys $containers "Env.SERVER_TOKENS")) "") }}


{{- /*
* Get the HTTPS_METHOD defined by containers w/ the same vhost, falling
* back to "redirect".
*/}}
{{- $https_method := or (first (groupByKeys $containers "Env.HTTPS_METHOD")) (or $globals.Env.HTTPS_METHOD "redirect") }}

{{- /*
* Get the SSL_POLICY defined by containers w/ the same vhost, falling
* back to empty string (use default).
Expand All @@ -425,29 +484,7 @@ server {
{{- /* Get the VIRTUAL_ROOT By containers w/ use fastcgi root */}}
{{- $vhost_root := or (first (groupByKeys $containers "Env.VIRTUAL_ROOT")) "/var/www/public" }}


{{- /* Get the first cert name defined by containers w/ the same vhost */}}
{{- $certName := (first (groupByKeys $containers "Env.CERT_NAME")) }}

{{- /* Get the best matching cert by name for the vhost. */}}
{{- $vhostCert := (closest (dir "/etc/nginx/certs") (printf "%s.crt" $host))}}

{{- /*
* vhostCert is actually a filename so remove any suffixes since they
* are added later.
*/}}
{{- $vhostCert := trimSuffix ".crt" $vhostCert }}
{{- $vhostCert := trimSuffix ".key" $vhostCert }}

{{- /*
* Use the cert specified on the container or fallback to the best vhost
* match.
*/}}
{{- $cert := (coalesce $certName $vhostCert) }}

{{- $is_https := (and (ne $https_method "nohttps") (ne $cert "") (exists (printf "/etc/nginx/certs/%s.crt" $cert)) (exists (printf "/etc/nginx/certs/%s.key" $cert))) }}

{{- if and $is_https (eq $https_method "redirect") }}
{{- if and $cert_ok (eq $https_method "redirect") }}
server {
server_name {{ $host }};
{{- if $server_tokens }}
Expand Down Expand Up @@ -485,19 +522,20 @@ server {
server_tokens {{ $server_tokens }};
{{- end }}
{{ $globals.access_log }}
{{- if or (not $is_https) (eq $https_method "noredirect") }}
{{- if or (eq $https_method "nohttps") (not $cert_ok) (eq $https_method "noredirect") }}
listen {{ $globals.external_http_port }} {{ $default_server }};
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_http_port }} {{ $default_server }};
{{- end }}
{{- end }}
{{- if $is_https }}
{{- if ne $https_method "nohttps" }}
listen {{ $globals.external_https_port }} ssl http2 {{ $default_server }};
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_https_port }} ssl http2 {{ $default_server }};
{{- end }}

{{- template "ssl_policy" (dict "ssl_policy" $ssl_policy) }}
{{- if $cert_ok }}
{{- template "ssl_policy" (dict "ssl_policy" $ssl_policy) }}

ssl_session_timeout 5m;
ssl_session_cache shared:SSL:50m;
Expand All @@ -506,22 +544,50 @@ server {
ssl_certificate /etc/nginx/certs/{{ (printf "%s.crt" $cert) }};
ssl_certificate_key /etc/nginx/certs/{{ (printf "%s.key" $cert) }};

{{- if (exists (printf "/etc/nginx/certs/%s.dhparam.pem" $cert)) }}
{{- if (exists (printf "/etc/nginx/certs/%s.dhparam.pem" $cert)) }}
ssl_dhparam {{ printf "/etc/nginx/certs/%s.dhparam.pem" $cert }};
{{- end }}
{{- end }}

{{- if (exists (printf "/etc/nginx/certs/%s.chain.pem" $cert)) }}
{{- if (exists (printf "/etc/nginx/certs/%s.chain.pem" $cert)) }}
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate {{ printf "/etc/nginx/certs/%s.chain.pem" $cert }};
{{- end }}
{{- end }}

{{- if (not (or (eq $https_method "noredirect") (eq $hsts "off"))) }}
{{- if (not (or (eq $https_method "noredirect") (eq $hsts "off"))) }}
set $sts_header "";
if ($https) {
set $sts_header "{{ trim $hsts }}";
}
add_header Strict-Transport-Security $sts_header always;
{{- end }}
{{- else if $globals.default_cert_ok }}
# No certificate found for this vhost, so use the default certificate and
# return an error code if the user connects via https.
ssl_certificate /etc/nginx/certs/default.crt;
ssl_certificate_key /etc/nginx/certs/default.key;
if ($https) {
return 500;
}
{{- else }}
# No certificate found for this vhost, so force nginx to emit a TLS error if
# the client connects via https.
{{- /*
* The alternative is to not provide an https server for this
* vhost, which would either cause the user to see the wrong
* vhost (if there is another vhost with a certificate) or a
* connection refused error (if there is no other vhost with a
* certificate). A TLS error is easier to troubleshoot, and is
* safer than serving the wrong vhost. Also see
* <https://serverfault.com/a/1044022>.
*/}}
ssl_ciphers aNULL;
set $empty "";
ssl_certificate data:$empty;
ssl_certificate_key data:$empty;
if ($https) {
return 444;
}
{{- end }}
{{- end }}

Expand Down Expand Up @@ -558,23 +624,4 @@ server {
}
{{- end }}
}

{{- if (and (not $is_https) (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }}
server {
server_name {{ $host }};
{{- if $server_tokens }}
server_tokens {{ $server_tokens }};
{{- end }}
listen {{ $globals.external_https_port }} ssl http2 {{ $default_server }};
{{- if $globals.enable_ipv6 }}
listen [::]:{{ $globals.external_https_port }} ssl http2 {{ $default_server }};
{{- end }}
{{ $globals.access_log }}
return 500;

ssl_certificate /etc/nginx/certs/default.crt;
ssl_certificate_key /etc/nginx/certs/default.key;
}
{{- end }}

{{- end }}