Hosts and proxies
Hosts and proxies in the Security guide.
The Host header is attacker input. Anything that builds an absolute URL from it (redirects, links, password-reset
mails) is wrong if it is not checked. security.allowedHosts lists the host names the server answers for: an exact
name (example.com), a name with a port (example.com:8443, otherwise any port matches) or a wildcard for
subdomains (*.example.com, which does not match example.com itself). Matching is case-insensitive and applies to
the host of the request URL. Any other host gets 400 before routing. The health path is exempt, so a load balancer that
probes by IP address works. A bare * is rejected. Without the option, a production handler sends one warning
through onWarning on its first request and serves that request as usual; allowedHosts: [] (accept any Host) is
refused in production unless acknowledgeNoHostCheck: true is set too.
import { compileHostRules } from "@shadow-js/dusk/server";
const allowed = compileHostRules(["example.com", "*.example.com", "admin.example.net:8443"]);
console.log(allowed("example.com"));
console.log(allowed("API.Example.com:443"));
console.log(allowed("example.com.evil.test"));
console.log(allowed("evil.test"));
console.log(allowed("admin.example.net:8443"));
console.log(allowed("admin.example.net:9000"));
true
true
false
false
true
falseBehind a reverse proxy, two separate facts are wrong unless you tell the server about the proxy, and two separate settings handle them:
| Setting | What it changes | What it does not change |
|---|---|---|
serve(handler, { trustProxy: true }) from @shadow-js/dusk/node |
The URL of the request: the host comes from the first value of X-Forwarded-Host, the scheme from the first value of X-Forwarded-Proto (http or https). This feeds event.url, the host check, the CSRF origin comparison, Secure cookies and HSTS. |
event.clientAddress |
security.trustProxy in createHandler |
event.clientAddress: read from a header (x-forwarded-for by default), counting hops entries from the right, optionally only when the connection comes from one of trustedPeers. An invalid entry falls back to the peer address. |
The URL, the host, the scheme |
Neither is on by default, and both are only safe when the proxy overwrites the header it sends instead of passing the
client's value on. X-Forwarded-For is read from the right because each proxy appends; X-Forwarded-Host and
X-Forwarded-Proto are read from the left, so the proxy has to set them, not append (see
Deploy for nginx). Set trustedPeers to the proxy's address when the
server is also reachable another way.
import { resolveClientAddress } from "@shadow-js/dusk/server";
const headers = new Headers({ "x-forwarded-for": "6.6.6.6, 1.2.3.4" });
console.log(resolveClientAddress("::ffff:10.0.0.2", headers, undefined));
console.log(resolveClientAddress("10.0.0.2", headers, { hops: 1 }));
console.log(resolveClientAddress("10.0.0.2", headers, { hops: 2 }));
console.log(resolveClientAddress("10.0.0.2", headers, { trustedPeers: ["192.168.0.1"] }));
console.log(resolveClientAddress("10.0.0.2", new Headers({ "x-forwarded-for": "not an address" }), {}));
console.log(
resolveClientAddress("10.0.0.2", new Headers({ "x-real-ip": "[2001:db8::1]:443" }), { header: "x-real-ip" }),
);
10.0.0.2
1.2.3.4
6.6.6.6
10.0.0.2
10.0.0.2
2001:0db8:0000:0000:0000:0000:0000:0001import { json, type RequestEvent } from "@shadow-js/dusk/server";
export const GET = (event: RequestEvent) =>
json({ address: event.clientAddress ?? null, host: event.url.host });