Allowed Hosts and HTTPS
The Host header is whatever the client wrote. An application that builds a password reset
link from it, keys a cache on it, names it in a redirect, or listens on an address a DNS
rebinding page can reach, is trusting a value anyone can set. AppOptions.AllowedHosts is the
list of hosts the application answers to, and everything else is refused before any of the
application runs.
app := muzak.New(muzak.AppOptions{
Title: settings.AppName,
// The names this service answers to. A request for any other host is
// refused with 421 before a route, a mount or the documentation sees it.
AllowedHosts: settings.AllowedHosts,
// Plain HTTP is redirected to https on one of the names above.
RedirectHTTPS: &muzak.RedirectHTTPSOptions{},
ClientIP: muzak.ClientIPOptions{TrustedProxies: settings.TrustedProxies},
})
How a host is matched
| Entry | Admits | Refuses |
|---|---|---|
example.com | example.com, EXAMPLE.COM, example.com., example.com:8080 | www.example.com, example.com.evil.com, example.com@evil.com |
*.example.com | api.example.com, a.b.example.com:443 | example.com, aexample.com, .example.com |
api.example.com:8443 | api.example.com:8443 | api.example.com, api.example.com:443 |
[::1] | [::1], [0:0::1]:8080 | ::1, [::1%25lo0] |
127.0.0.1 | 127.0.0.1, 127.0.0.1:5000 | 127.1, 2130706433, [::ffff:127.0.0.1] |
xn--bcher-kva.de | xn--bcher-kva.de, XN--BCHER-KVA.DE | the same name in UTF-8 |
- Case is ignored for ASCII letters only. A letter outside ASCII that Unicode folds to an
ASCII one, the Kelvin sign to
kor the long s tos, is never taken for it. - One trailing dot on the request's host, the fully qualified spelling, is ignored.
- An entry without a port allows any port. An entry with a port allows that port only, so a
request that names no port matches only an entry that names none. A request whose
Hostends in an empty port, as inexample.com:, names none. - A leading
*.allows every subdomain, of one label or more, and not the domain itself. List the domain too when it is served. - An IPv6 address is written in brackets and compared in its canonical form, so every spelling of one address matches. A zone is never accepted.
- An internationalized name is written in its punycode (
xn--) form, which is what a client sends. X-Forwarded-Host,Forwarded: host=and every other forwarding header are ignored, from any peer. Behind a proxy, list theHostthe proxy passes on.
An entry that can never match is a build error, with every bad entry reported at once: an empty
one, or one with a scheme, a path, credentials, a query or fragment, whitespace, a * anywhere
but a leading *., a bare IPv6 address, a trailing colon with no port after it, a port of 0 or
over 65535, a wildcard in front of an address, a character outside ASCII, or anything else that
is not a host name or an address. Surrounding whitespace is ignored, since a list read from the
environment is usually split at commas.
Unset, the default, no check is made and nothing is installed, so it costs nothing.
The refusal
A request for a host that is not listed is answered 421 Misdirected Request through the error
renderer, so it carries the error envelope (or a problem, with
Problem Details on), the request identifier, the security
headers and Cache-Control: no-store:
{
"error": {
"code": "misdirected_request",
"message": "This server does not serve the requested host.",
"status": 421
},
"request_id": "0611f4b2-2f0a-7b57-9c1a-6e6a2e2f9b31"
}
The host is not echoed. It is logged at warn level, on every refused request, quoted with every
byte that is not printable ASCII escaped, so a header crafted to forge a log line records as inert
text, and the access log records the 421. A scanner that sends foreign hosts all day therefore
writes a warning per request; filter the message in your log pipeline if that is noise to you.
421 rather than 400 because RFC 9110 defines it for exactly this: a request "directed at a server that is unable or unwilling to produce an authoritative response for the target URI". It is also the status an HTTP/2 or HTTP/3 client acts on. A browser that reused one connection for two hosts its certificate covers retries on a connection of its own when told 421, where a 400 would end the request as the client's fault.
The check runs before anything else of the application: before the CORS policy, the cross-origin
protection, the middleware installed with App.Use, routing, mounts, frontends and the
documentation. It runs inside request identification, the security headers, the locale and the
access log, so a refusal is recorded like any other response and translated like any other error.
The message is translatable at muzak.http.421.
The one thing answered ahead of it is a health probe. A platform
probes a replica by its own address, with whatever Host that makes and over plain HTTP, so the
health endpoints answer for any host and are never redirected; otherwise every probe would be
refused and every replica taken out of rotation. They say only whether the process is up and
ready, unless HealthOptions.ReportChecks adds the name and outcome of each check, which any
page that reaches the port through DNS rebinding can then read too. Leave ReportChecks off
where the port is reachable from outside the platform.
Two things net/http settles before the application sees the request:
- A request with an absolute target,
GET http://evil.com/ HTTP/1.1, has its host taken from the target rather than theHostheader, as RFC 9112 requires, and that is the host checked. - A request with two
Hostheaders, or aHostholding a byte no host carries, such as one outside ASCII or the@ofexample.com@evil.com, is refused by net/http with400before any handler runs. The allowlist refuses the same values for a request that reachesServeHTTPsome other way.
An HTTP/1.0 request with no Host at all reaches the application with an empty host, which no
entry matches, so it is refused with 421.
The two host lists of a WebSocket are separate. A WebSocket route's
own WSOptions.AllowedHosts still decides which handshakes count as same-origin, and is not filled
in from AppOptions.AllowedHosts; with AppOptions.AllowedHosts set, a handshake for any host it
does not list never reaches the route at all.
Redirecting plain HTTP
app := muzak.New(muzak.AppOptions{
AllowedHosts: []string{"example.com", "www.example.com"},
RedirectHTTPS: &muzak.RedirectHTTPSOptions{},
})
A request that arrived over plain HTTP is answered with a redirect to the same host, path and query over https:
| Method | Status | Why |
|---|---|---|
GET, HEAD | 301 Moved Permanently | Understood by every client, and kept by a browser, so the next navigation goes to https without a plaintext round trip. |
| everything else | 308 Permanent Redirect | Keeps the method and the body, which a client following a 301 for a POST may turn into a GET without one. |
The redirect has no body and sends no Strict-Transport-Security; SecurityHeaders sends that
on responses served over TLS, which is where a browser honours it. A redirect is a convenience for
navigation: the body of a request that was redirected has already crossed the network in the
clear, and HSTS is what keeps a returning browser from sending it at all.
Which host it names
A redirect is only as trustworthy as the host it names, so it never names one the application has not vouched for:
- With
AllowedHostsset, the request's own host is kept, once the allowlist has admitted it, lowercased and without the trailing dot. A host the allowlist refuses is refused, never redirected. RedirectHTTPSOptions.Hostnames one canonical host every redirect is sent to instead, which also lets the redirect be used without an allowlist.- With neither, enabling the redirect is a build error: a forged
Hostwould otherwise make it a redirect to anywhere.
The port the request named is never kept, because it is the one plain HTTP was served on. Set
Port when https is served somewhere other than 443:
muzak.RedirectHTTPSOptions{Port: 8443} // https://example.com:8443/...
muzak.RedirectHTTPSOptions{Host: "www.example.com"} // https://www.example.com/...
muzak.RedirectHTTPSOptions{Host: "www.example.com:8443"} // https://www.example.com:8443/...
Host is written as an AllowedHosts entry is, without a wildcard, since a redirect needs one
host to send the client to. A port of 0 or 443 is left out of the URL. A Host that names a
port while Port is set too, a wildcard Host, and a Port outside 0 to 65535 are build
errors.
The path and query
The path and query are copied with every byte that is not plainly allowed in them
percent-encoded: a control character, a space, a backslash, a quote or a byte outside ASCII never
reaches the Location header, an escape the client wrote is kept as written, and a stray %
becomes %25. A path that does not begin with /, the * of OPTIONS * say, is redirected to
the root. Because the Location always names its host, a path beginning //evil.com or /\evil.com
is a path on the vouched host and nothing else.
Behind a proxy
Behind a proxy that terminates TLS, every request arrives as plain HTTP, and only the proxy knows
what the client used. A request counts as https when the peer that sent it is a proxy named in
ClientIPOptions.TrustedProxies and that proxy says so:
ClientIPOptions.Header | The scheme is read from |
|---|---|
unset, X-Forwarded-For, X-Real-IP, anything else | X-Forwarded-Proto |
Forwarded | the proto parameter of Forwarded |
Only the value the trusted proxy itself wrote is read, which is the last one: the last
comma-separated value of the last X-Forwarded-Proto line, or the proto of the last Forwarded
entry. An earlier value may have come from the client through a proxy that appends rather than
replaces, and a client that could claim https would have its plain request served, and kept by a
cache, as though it were secure. From a peer that is not trusted, the header is ignored whatever
it says. Responses whose treatment depended on the header carry it in Vary.
The proxy has to set the header. One that does not, or a server listening on a Unix socket, where no peer address can be trusted, sees every request as plain and redirects every one of them. A chain of proxies has to pass the edge's value through rather than write its own hop's scheme.
What is never redirected
A request for an ACME HTTP-01 challenge, /.well-known/acme-challenge/ followed by one token of
base64url characters, is served over plain HTTP, because a certificate authority validates a host
that has no certificate yet. Nothing that only begins like one is exempt: a second segment, a ..
or an encoded slash is redirected like any other path.
The health endpoints are not redirected either, for the reason given above: a platform probes them over plain HTTP.
Checking it works
curl -si http://localhost:8080/ -H 'Host: evil.com' # 421
curl -si http://localhost:8080/a?b=1 -H 'Host: example.com' # 301 to https://example.com/a?b=1
curl -si http://localhost:8080/a -H 'Host: example.com' \
-H 'X-Forwarded-Proto: https' # 301 unless the peer is a trusted proxy
Where to go next
Behind a Proxy covers naming the proxy whose headers are believed, TLS serving https directly, and Health Checks the probes that are answered ahead of both checks.