You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A wildcard listen (0.0.0.0) makes the Host allowlist refuse every LAN request — and that is what the compose distribution ships #2569
Found while verifying the certificate-name rules for #2562, by probing the shipped guard rather than reasoning about it.
The mechanism
HostHeaderGuard.IsAllowedHost(host, networkListenIp) (PerformanceMonitor.Common/Services/HostHeaderGuard.cs) accepts a request only when the Host header is empty, localhost, a loopback literal, or an IP literal equal to networkListenIp. Both hosts derive networkListenIp straight from the config — IPAddress.Parse(network.Listen.Trim()) — so with "listen": "0.0.0.0" the only IP that satisfies the comparison is the string 0.0.0.0, which no client ever sends.
Measured against the shipped guard:
listen=192.168.1.205 Host=192.168.1.205 -> served
listen=192.168.1.205 Host=darling.corp.local -> 400 REFUSED
listen=192.168.1.205 Host=localhost -> served
listen=0.0.0.0 Host=192.168.1.205 -> 400 REFUSED <-- every real LAN client
listen=0.0.0.0 Host=0.0.0.0 -> served
listen=0.0.0.0 Host=localhost -> served
The guard is installed as the first middleware in both bind modes on the web host, Darling's MCP host and Lite's (that placement is pinned by the #1648 wiring tests), so this happens before any auth decision, route handler or static file. A wildcard-bound endpoint answers loopback and refuses everything else with a 400.
Scope of what I verified: the guard function's behaviour directly, and its position in the pipeline from the existing wiring pins. I have not stood up a container and watched a browser get the 400 — that is the remaining confirmation, and it is worth doing before the fix, because it is the cheap way to be sure nothing downstream rewrites Host.
Why it matters more than it looks
Darling/compose/darling.sample.json ships "listen": "0.0.0.0" for both the web dashboard and MCP. The compose quickstart's step 1 says to edit "servers, tokens, alerting" — not listen — and its step 4 then says:
4. web dashboard: http://<host>:5153 · MCP: http://<host>:5152 (bearer token)
So a deployment that follows the documented quickstart exactly gets a dashboard that 400s every browser. That is also why this has plausibly gone unnoticed: 0.0.0.0 is the natural choice in a container and the wrong-looking one on a Windows box, and both monitor boxes here set a specific IP, which is the configuration that works.
0.0.0.0 is offered as a legitimate value in three more places: the web and mcp blocks of the shipped darling.sample.json ("0.0.0.0" = all interfaces), and --configure-network's own degrade text (use a specific IP, or 0.0.0.0 for all interfaces).
What the fix has to preserve
The guard exists to stop DNS rebinding (#1576, extended to both modes and both hosts in #1648): a browser on the host loads attacker content, the attacker's hostname re-resolves to the bind address, and the request arrives same-origin. The property that defeats that is comparing the Host against addresses we bind, and never accepting a hostname — an attacker's rebound name is a hostname, so it can never match an IP literal. Any fix must keep that.
Three options, and they are not exclusive:
Resolve a wildcard to the addresses it actually binds. At bind time, enumerate the machine's own unicast addresses and accept a Host that is an IP literal matching any of them. Keeps the "IP literals only" property intact, so the rebinding guard is unchanged in strength. Costs one NetworkInterface enumeration per start, and needs a decision about addresses that appear after start.
An explicit hostNames allowlist in the network block. Strictly more useful than (1), because it also fixes a problem Add an HTTPS listener for the web dashboard — prerequisite for OIDC, and the shared token currently crosses the LAN in the clear #2562 just documented: the guard's IP-literal rule means a LAN client must browse by IP, so the DNS-name certificate an internal CA issues by default can never match the dashboard's TLS listener. Naming the hostname would let a normal certificate work. It does re-admit hostnames, so it must be an explicit opt-in list, never a wildcard, and each entry must be compared exactly.
Refuse 0.0.0.0 at config time, degrading to loopback with a reason. The smallest change and the most honest about today's behaviour, but it takes away the option the container path actually wants.
My reading is (1) as the fix and (2) as the follow-up that #2562 wants anyway — but the compose sample needs changing under any of them, and that part is not optional.
Related: #2562 (the dashboard's HTTPS listener, where the IP-literal rule forces an iPAddress SAN), #1576 / #1648 (the guard).
A wildcard listen now accepts any IP literal. Verified end-to-end through a real Kestrel pipeline, which also closes the confirmation I flagged as outstanding when filing — before: Host: 192.168.1.205 -> 400; after: 200.
The fix is not option (1) from this issue. Enumerating the machine's own interfaces at bind time would not have fixed the case that motivated the bug: inside a container the local interfaces are the container's (172.18.0.3), while the address the browser used is the host's published one, which the container cannot see. NAT, port forwarding and multi-homing break it identically, and silently, as a 400. It would have looked like a fix and left compose broken.
The rebinding defence is unchanged, which is what makes this a fix rather than a loosening: a rebind arrives with a hostname by construction, and a hostname still never passes — nor does an IP-shaped prefix on an attacker domain (192.168.1.205.evil.com), verified in the same pipeline. A specific listen IP is untouched and still rejects every other literal, pinned so the wildcard arm cannot leak into it.
Option (2) — an explicit hostNames allowlist — is still worth having and is now the ONLY way to reach the dashboard by DNS name, which also blocks a normal DNS-name certificate from ever matching its TLS listener (#2562). Not filed yet; say the word and I will.
Found while verifying the certificate-name rules for #2562, by probing the shipped guard rather than reasoning about it.
The mechanism
HostHeaderGuard.IsAllowedHost(host, networkListenIp)(PerformanceMonitor.Common/Services/HostHeaderGuard.cs) accepts a request only when theHostheader is empty,localhost, a loopback literal, or an IP literal equal tonetworkListenIp. Both hosts derivenetworkListenIpstraight from the config —IPAddress.Parse(network.Listen.Trim())— so with"listen": "0.0.0.0"the only IP that satisfies the comparison is the string0.0.0.0, which no client ever sends.Measured against the shipped guard:
The guard is installed as the first middleware in both bind modes on the web host, Darling's MCP host and Lite's (that placement is pinned by the #1648 wiring tests), so this happens before any auth decision, route handler or static file. A wildcard-bound endpoint answers loopback and refuses everything else with a 400.
Scope of what I verified: the guard function's behaviour directly, and its position in the pipeline from the existing wiring pins. I have not stood up a container and watched a browser get the 400 — that is the remaining confirmation, and it is worth doing before the fix, because it is the cheap way to be sure nothing downstream rewrites
Host.Why it matters more than it looks
Darling/compose/darling.sample.jsonships"listen": "0.0.0.0"for both the web dashboard and MCP. The compose quickstart's step 1 says to edit "servers, tokens, alerting" — notlisten— and its step 4 then says:So a deployment that follows the documented quickstart exactly gets a dashboard that 400s every browser. That is also why this has plausibly gone unnoticed:
0.0.0.0is the natural choice in a container and the wrong-looking one on a Windows box, and both monitor boxes here set a specific IP, which is the configuration that works.0.0.0.0is offered as a legitimate value in three more places: thewebandmcpblocks of the shippeddarling.sample.json("0.0.0.0" = all interfaces), and--configure-network's own degrade text (use a specific IP, or 0.0.0.0 for all interfaces).What the fix has to preserve
The guard exists to stop DNS rebinding (#1576, extended to both modes and both hosts in #1648): a browser on the host loads attacker content, the attacker's hostname re-resolves to the bind address, and the request arrives same-origin. The property that defeats that is comparing the Host against addresses we bind, and never accepting a hostname — an attacker's rebound name is a hostname, so it can never match an IP literal. Any fix must keep that.
Three options, and they are not exclusive:
NetworkInterfaceenumeration per start, and needs a decision about addresses that appear after start.hostNamesallowlist in thenetworkblock. Strictly more useful than (1), because it also fixes a problem Add an HTTPS listener for the web dashboard — prerequisite for OIDC, and the shared token currently crosses the LAN in the clear #2562 just documented: the guard's IP-literal rule means a LAN client must browse by IP, so the DNS-name certificate an internal CA issues by default can never match the dashboard's TLS listener. Naming the hostname would let a normal certificate work. It does re-admit hostnames, so it must be an explicit opt-in list, never a wildcard, and each entry must be compared exactly.0.0.0.0at config time, degrading to loopback with a reason. The smallest change and the most honest about today's behaviour, but it takes away the option the container path actually wants.My reading is (1) as the fix and (2) as the follow-up that #2562 wants anyway — but the compose sample needs changing under any of them, and that part is not optional.
Related: #2562 (the dashboard's HTTPS listener, where the IP-literal rule forces an iPAddress SAN), #1576 / #1648 (the guard).