Host matching uses prefix comparison, so an attacker-chosen Host suffix reaches a service #32

Closed
opened 2026-09-29 13:24:29 +00:00 by james.oates · 0 comments
Owner

Problem

Service host matching is a prefix comparison (src/proxy.rs:256 and :327):

s.routing_hosts.iter().any(|h| host.starts_with(h.as_str()))

So a service declaring routing_hosts = ["test.example.com"] also answers requests carrying any Host that starts with that string. Verified against a live backend:

Request Host Reaches backend?
test.example.com yes correct
test.example.com.attacker.example yes wrong — attacker picks the suffix
sub.test.example.com no the case callers actually want, rejected

The prefix match admits exactly the wrong direction.

Impact

The Host header is attacker-controlled, and rewrite_host defaults to false — ER deliberately forwards the original Host so S3 and cookie domains work. So the backend receives test.example.com.attacker.example verbatim. Any absolute URL the backend builds from the Host it was given (password-reset links, email confirmations, redirects) then points at the attacker's domain, and any cache keyed on Host gets a poisoned entry.

Reaching a listener is still gated by access rules, so this is not remotely exploitable on a listener an attacker can't reach. On any internet-facing listener — a Cloudflare Tunnel binding, for instance — it is.

Root cause

Not a design decision. starts_with dates from the initial commit (b9f5635), when the host value still carried :port — it was port tolerance. authority_host, which strips the port, only arrived with IPv6 support in #25 (d9e68cd). The prefix match has been dead weight ever since, and no test depends on prefix semantics.

Note ER already has the correct rule in a third place: l4::match_route does exact-then-*.suffix for SNI, deliberately not matching the bare apex, mirroring wildcard-cert SAN rules. [[static_sites]] uses a fourth rule (exact only). SniResolver uses a fifth (exact, else strip one label) — that one is legitimately different, since cert resolution genuinely has its own semantics.

Fix

One shared matcher, promoting the rule l4::match_route already implements, used by services, static sites and L4 SNI:

pub fn host_matches(pattern: &str, host: &str) -> bool {
    if pattern == host {
        return true;
    }
    pattern
        .strip_prefix("*.")
        .and_then(|suffix| host.strip_suffix(suffix))
        .is_some_and(|prefix| prefix.ends_with('.'))
}

Exact, or an explicit *.sub.domain wildcard that matches any deeper label but never the bare apex. SniResolver is left alone.

Behaviour change

Any deployment relying on the prefix match loses it. That should be nobody — the only thing it buys is answering hostnames you didn't configure — but it is a behaviour change, so it wants a minor version and a release note. Hosts wanting subdomain coverage gain *.example.com, which previously did not work at all.

Plan

obsidian/Personal/Projects/edge-router/plans/2026-09-29-honest-fallbacks-and-host-matching.md, Task 5. The same plan also fixes a down backend reporting 404 instead of 503, which is what prompted the review that found this.

## Problem Service host matching is a prefix comparison (`src/proxy.rs:256` and `:327`): ```rust s.routing_hosts.iter().any(|h| host.starts_with(h.as_str())) ``` So a service declaring `routing_hosts = ["test.example.com"]` also answers requests carrying any Host that *starts with* that string. Verified against a live backend: | Request Host | Reaches backend? | | |---|---|---| | `test.example.com` | yes | correct | | `test.example.com.attacker.example` | **yes** | wrong — attacker picks the suffix | | `sub.test.example.com` | no | the case callers actually want, rejected | The prefix match admits exactly the wrong direction. ## Impact The Host header is attacker-controlled, and `rewrite_host` defaults to `false` — ER deliberately forwards the original Host so S3 and cookie domains work. So the backend receives `test.example.com.attacker.example` verbatim. Any absolute URL the backend builds from the Host it was given (password-reset links, email confirmations, redirects) then points at the attacker's domain, and any cache keyed on Host gets a poisoned entry. Reaching a listener is still gated by access rules, so this is not remotely exploitable on a listener an attacker can't reach. On any internet-facing listener — a Cloudflare Tunnel binding, for instance — it is. ## Root cause Not a design decision. `starts_with` dates from the initial commit (`b9f5635`), when the `host` value still carried `:port` — it was port tolerance. `authority_host`, which strips the port, only arrived with IPv6 support in #25 (`d9e68cd`). The prefix match has been dead weight ever since, and no test depends on prefix semantics. Note ER already has the correct rule in a third place: `l4::match_route` does exact-then-`*.suffix` for SNI, deliberately not matching the bare apex, mirroring wildcard-cert SAN rules. `[[static_sites]]` uses a fourth rule (exact only). `SniResolver` uses a fifth (exact, else strip one label) — that one is legitimately different, since cert resolution genuinely has its own semantics. ## Fix One shared matcher, promoting the rule `l4::match_route` already implements, used by services, static sites and L4 SNI: ```rust pub fn host_matches(pattern: &str, host: &str) -> bool { if pattern == host { return true; } pattern .strip_prefix("*.") .and_then(|suffix| host.strip_suffix(suffix)) .is_some_and(|prefix| prefix.ends_with('.')) } ``` Exact, or an explicit `*.sub.domain` wildcard that matches any deeper label but never the bare apex. `SniResolver` is left alone. ## Behaviour change Any deployment relying on the prefix match loses it. That should be nobody — the only thing it buys is answering hostnames you didn't configure — but it is a behaviour change, so it wants a minor version and a release note. Hosts wanting subdomain coverage gain `*.example.com`, which previously did not work at all. ## Plan `obsidian/Personal/Projects/edge-router/plans/2026-09-29-honest-fallbacks-and-host-matching.md`, Task 5. The same plan also fixes a down backend reporting 404 instead of 503, which is what prompted the review that found this.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#32
No description provided.