You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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

EntryAdmitsRefuses
example.comexample.com, EXAMPLE.COM, example.com., example.com:8080www.example.com, example.com.evil.com, example.com@evil.com
*.example.comapi.example.com, a.b.example.com:443example.com, aexample.com, .example.com
api.example.com:8443api.example.com:8443api.example.com, api.example.com:443
[::1][::1], [0:0::1]:8080::1, [::1%25lo0]
127.0.0.1127.0.0.1, 127.0.0.1:5000127.1, 2130706433, [::ffff:127.0.0.1]
xn--bcher-kva.dexn--bcher-kva.de, XN--BCHER-KVA.DEthe 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 k or the long s to s, 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 Host ends in an empty port, as in example.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 the Host the 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 the Host header, as RFC 9112 requires, and that is the host checked.
  • A request with two Host headers, or a Host holding a byte no host carries, such as one outside ASCII or the @ of example.com@evil.com, is refused by net/http with 400 before any handler runs. The allowlist refuses the same values for a request that reaches ServeHTTP some 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:

MethodStatusWhy
GET, HEAD301 Moved PermanentlyUnderstood by every client, and kept by a browser, so the next navigation goes to https without a plaintext round trip.
everything else308 Permanent RedirectKeeps 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 AllowedHosts set, 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.Host names 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 Host would 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.HeaderThe scheme is read from
unset, X-Forwarded-For, X-Real-IP, anything elseX-Forwarded-Proto
Forwardedthe 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.