Routing and TLS disagree on wildcard depth, so a multi-label name is served the self-signed fallback #56

Closed
opened 2026-10-26 15:37:49 +00:00 by james.oates · 1 comment
Owner

From the v2.0.0 security audit.

host_matches (src/proxy.rs:76) matches a wildcard at any depth — prefix.ends_with('.') is satisfied by deep.sub., and the repo's own test asserts *.example.com matches deep.sub.example.com. l4::match_route uses the same rule.

The TLS side is single-label: SniResolver::lookup builds exactly one wildcard candidate from the first dot, and cert_manager::covering_wildcard is single-label too. That side is the RFC 6125 one — a wildcard covers exactly one label — which is why Let's Encrypt genuinely will not cover a.b.oates.ws with *.oates.ws.

Consequence

A service with routing_hosts = ["*.oates.ws"]. A request for a.b.oates.ws routes to it. The resolver's candidates are a.b.oates.ws (absent) and *.b.oates.ws (absent), so nothing matches, no tombstone is hit, and lookup falls through to the self-signed edge-router.local fallback — which is on by default.

That is a name covered by a route host being served the fallback, which the #38 invariant says must not happen. It is not a wrong trusted identity, so a verifying client fails; the harm is that a non-verifying client proceeds and the operator sees the route "working" on a bogus identity instead of a refused handshake.

Also note the doc comment on host_matches claims the any-depth behaviour mirrors "wildcard-cert SAN semantics". It does not — that is precisely the single-label rule.

Fix

Make host_matches single-label, so routing agrees with what a certificate can actually cover. Breaking for anyone relying on multi-label wildcard routing, and v2.0.0 is already breaking — but it was raised after the forwarded-header fixes landed, so it is a decision rather than something to slip in.

The alternative, making the TLS side multi-label, is wrong: there is no certificate to find, so it would only move the failure.

Needs a test asserting the three consumers agree, since the current divergence is asserted by a test.

From the v2.0.0 security audit. `host_matches` (`src/proxy.rs:76`) matches a wildcard at **any** depth — `prefix.ends_with('.')` is satisfied by `deep.sub.`, and the repo's own test asserts `*.example.com` matches `deep.sub.example.com`. `l4::match_route` uses the same rule. The TLS side is **single-label**: `SniResolver::lookup` builds exactly one wildcard candidate from the first dot, and `cert_manager::covering_wildcard` is single-label too. That side is the RFC 6125 one — a wildcard covers exactly one label — which is why Let's Encrypt genuinely will not cover `a.b.oates.ws` with `*.oates.ws`. ## Consequence A service with `routing_hosts = ["*.oates.ws"]`. A request for `a.b.oates.ws` routes to it. The resolver's candidates are `a.b.oates.ws` (absent) and `*.b.oates.ws` (absent), so nothing matches, no tombstone is hit, and `lookup` falls through to the self-signed `edge-router.local` fallback — which is on by default. That is a name covered by a route host being served the fallback, which the #38 invariant says must not happen. It is not a wrong *trusted* identity, so a verifying client fails; the harm is that a non-verifying client proceeds and the operator sees the route "working" on a bogus identity instead of a refused handshake. Also note the doc comment on `host_matches` claims the any-depth behaviour mirrors "wildcard-cert SAN semantics". It does not — that is precisely the single-label rule. ## Fix Make `host_matches` single-label, so routing agrees with what a certificate can actually cover. Breaking for anyone relying on multi-label wildcard routing, and v2.0.0 is already breaking — but it was raised after the forwarded-header fixes landed, so it is a decision rather than something to slip in. The alternative, making the TLS side multi-label, is wrong: there is no certificate to find, so it would only move the failure. Needs a test asserting the three consumers agree, since the current divergence is asserted *by* a test.
Author
Owner

Fixed in 5362126. host_matches now covers exactly one label, so routing agrees with what a certificate can cover.

The multi-label assertion in host_matches_wildcard_is_explicit was flipped, which this issue named as part of the fix. Added .example.com (an empty label) as a negative case while there.

The test that matters is new and cross-module: routing_and_certificate_coverage_agree_on_wildcard_depth asserts proxy::host_matches and cert_manager::covering_wildcard give the same answer for a covered name, a two-label name and the apex. The previous divergence was asserted by a test rather than merely unnoticed, so pinning the agreement is the part that stops it recurring.

l4::match_route shares host_matches, so SNI matching narrowed with it — test_wildcard_does_not_match_bare_apex still passes.

Breaking: a route relying on multi-label wildcard matching stops matching; write the intermediate wildcard (*.b.oates.ws) or an exact host. Needs a release-notes line.

354 tests green, clippy clean, fmt clean in rust:1.98.0-bookworm.

Fixed in `5362126`. `host_matches` now covers exactly one label, so routing agrees with what a certificate can cover. The multi-label assertion in `host_matches_wildcard_is_explicit` was flipped, which this issue named as part of the fix. Added `.example.com` (an empty label) as a negative case while there. The test that matters is new and cross-module: `routing_and_certificate_coverage_agree_on_wildcard_depth` asserts `proxy::host_matches` and `cert_manager::covering_wildcard` give the same answer for a covered name, a two-label name and the apex. The previous divergence was asserted *by* a test rather than merely unnoticed, so pinning the agreement is the part that stops it recurring. `l4::match_route` shares `host_matches`, so SNI matching narrowed with it — `test_wildcard_does_not_match_bare_apex` still passes. **Breaking:** a route relying on multi-label wildcard matching stops matching; write the intermediate wildcard (`*.b.oates.ws`) or an exact host. Needs a release-notes line. 354 tests green, clippy clean, fmt clean in `rust:1.98.0-bookworm`.
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#56
No description provided.