[]previous and next page

Hosts and proxies

Hosts and proxies in the Security guide.

For agentsUpdated

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.

hosts.tsts
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"));
Expected output
true
true
false
false
true
false

Behind 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.

client-address.tsts
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" }),
);
Expected output
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:0001
src/routes/api/whoami.tsts
import { json, type RequestEvent } from "@shadow-js/dusk/server";

export const GET = (event: RequestEvent) =>
  json({ address: event.clientAddress ?? null, host: event.url.host });