Skip to content

Commit 1ada5c0

Browse files
kriszypclaude
andcommitted
Fix a real regression from my own isolation fix: dropped wildcard black-holed exact routes
Round 8 (codex + gemini + grok + harper-domain) reviewed the prior commit's route-isolation fix and found I'd introduced exactly the class of bug it was meant to prevent, just inverted: RouteTable::resolve() checked dropped_wildcard before the exact-match lookup, so any SNI matching a dropped wildcard's suffix returned None immediately — including a perfectly healthy, fully-built exact route at that same SNI. One tenant's wildcard misconfiguration (e.g. `*.acme.com` missing `protocol: 'http'`) would black-hole every co-tenant with an exact route under that suffix (`api.acme.com`, ACME challenge routes, etc.), which is worse than the fall-through bug being fixed. Root cause: I checked both dropped sets before any live lookup, on the theory that "a poison entry should always win." That's wrong — a poison entry must only suppress a fallback *broader* than itself, never something *more specific*. Fixed by checking exact (live, then dropped) before moving to wildcard (live, then dropped) before default — each dropped set immediately follows its same-specificity live counterpart, so an exact match always wins regardless of wildcard state. Added a Rust unit test reproducing the exact scenario (healthy exact route + dropped sibling wildcard) that fails against the previous ordering and passes against this one — verified by reverting and reconfirming the failure before restoring. Also from round 8: last-good carry-forward (designed for a transient cert-rotation race that heals on the next reconcile) was reaching the new protocol/no-carrier rejections too, which are permanent config errors that never heal — so a route could silently keep serving a stale, previous version indefinitely across every later reconcile, with the operator's `updateConfig` reporting success while their edit quietly never took effect. Extracted the two protocol/carrier checks into a standalone validate_route_protocol_declaration(), called before build_route in the build_route_table loop, and given its own never-carry-forward path — a permanent rejection now always drops the route on a hot-swap, same as on initial build. Added a test for this too. Also narrowed the http2 warning in parse_route_spec: it fired for both xForwardedFor and forwardFingerprint, but xForwardedFor+http2 is always a hard rejection (build_route), so that branch was dead and misleadingly implied the route degrades gracefully when it's actually dropped outright. cargo build/clippy clean, cargo test 116/116, npm test 115/115. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b65a1ca commit 1ada5c0

2 files changed

Lines changed: 110 additions & 29 deletions

File tree

src/proxy.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -816,18 +816,15 @@ fn parse_route_spec(r: &JsRouteConfig) -> Result<RouteSpec> {
816816
}
817817
// xForwardedFor + http2 is a hard error (build_route, router.rs) since it's unconditionally
818818
// unsafe — an h2 client's XFF would be neither injected nor stripped, forwarding an arbitrary
819-
// client-supplied value as if authoritative. A header-carried forwardFingerprint in the same
820-
// spot is intentionally only a warning here, not a hard error, even though the risk is the
821-
// same in kind (an h2 client can forge X-JA3/X-JA4, not merely go unforwarded) — see
822-
// README.md's "Forwarding the fingerprint downstream" section for the tradeoff this leaves
823-
// open and why it isn't a hard error like XFF. Name whichever mode actually triggered this so
824-
// the message doesn't blame X-Forwarded-For when the route never configured it.
825-
if requires_http && spec.http2 {
826-
let mode_desc = if source_address_mode == SourceAddressMode::XForwardedFor {
827-
"xForwardedFor"
828-
} else {
829-
"a header-carried forwardFingerprint (X-JA3/X-JA4)"
830-
};
819+
// client-supplied value as if authoritative — so it's excluded here: this warning would always
820+
// be immediately followed by that hard rejection, misleadingly implying the route still works
821+
// (minus the header) when the whole route is in fact dropped. A header-carried
822+
// forwardFingerprint in the same spot is intentionally only a warning, not a hard error, even
823+
// though the risk is the same in kind (an h2 client can forge X-JA3/X-JA4, not merely go
824+
// unforwarded) — see README.md's "Forwarding the fingerprint downstream" section for the
825+
// tradeoff this leaves open and why it isn't a hard error like XFF.
826+
if requires_http && spec.http2 && source_address_mode != SourceAddressMode::XForwardedFor {
827+
let mode_desc = "a header-carried forwardFingerprint (X-JA3/X-JA4)";
831828
eprintln!(
832829
"symphony: route '{}': {mode_desc} has no effect for any client that negotiates h2 (http2=true) — injection and client-supplied-header stripping are both skipped, so an h2 client's own value reaches the upstream unmodified; consider sourceAddressHeader='proxyProtocolV2'",
833830
spec.sni

src/router.rs

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -236,25 +236,32 @@ impl RouteTable {
236236
};
237237

238238
// A route rejected outright (no last-good to carry forward) must fail closed, not fall
239-
// through to a broader wildcard or the default route: it was configured for this SNI, just
240-
// rejected, which is not the same as "no route was ever configured here." Checked before
241-
// any match below, exact or wildcard, so a poisoned entry always wins over a fallback.
242-
if self.dropped_exact.contains(sni) || self.dropped_wildcard.iter().any(|suffix| wildcard_suffix_matches(sni, suffix)) {
243-
return None;
244-
}
245-
246-
// Exact match first
239+
// through to a broader fallback: it was configured for this SNI, just rejected, which is
240+
// not the same as "no route was ever configured here." But a poison entry must only ever
241+
// suppress a fallback *broader* than itself — it must never shadow a route that is more
242+
// specific than the poison. So each dropped set is checked immediately after its
243+
// same-specificity live counterpart, not before it: a healthy exact route always wins over
244+
// a dropped wildcard at the same suffix (checking dropped_wildcard first would incorrectly
245+
// black-hole every exact route under a wildcard that happened to fail validation).
246+
247+
// Exact, live or dropped — most specific, decided first.
247248
if let Some(r) = self.exact.get(sni) {
248249
return Some(r);
249250
}
251+
if self.dropped_exact.contains(sni) {
252+
return None;
253+
}
250254

251-
// Wildcard: match exactly one left-most label against stored suffixes.
255+
// Wildcard, live or dropped — match exactly one left-most label against stored suffixes.
252256
// e.g. "foo.example.com" matches "*.example.com" but "a.b.example.com" does not.
253257
for (suffix, route) in &self.wildcard {
254258
if wildcard_suffix_matches(sni, suffix) {
255259
return Some(route);
256260
}
257261
}
262+
if self.dropped_wildcard.iter().any(|suffix| wildcard_suffix_matches(sni, suffix)) {
263+
return None;
264+
}
258265

259266
self.default.as_ref()
260267
}
@@ -359,6 +366,29 @@ pub fn build_route_table(
359366
let mut dropped_wildcard: Vec<Arc<str>> = Vec::new();
360367

361368
for spec in specs {
369+
// The protocol/no-carrier declaration checks are validated before any cert/TLS work, and
370+
// deliberately never carried forward on a hot-swap even if a last-good route exists: unlike
371+
// a cert failure (a transient race between two non-atomic file writes that heals on its own
372+
// next reconcile), a missing declaration or a passthrough-with-no-carrier config is a
373+
// permanent config error — it never heals without a human editing the config. Applying
374+
// cert-style carry-forward to it would silently keep serving the *previous* route
375+
// indefinitely, including ignoring any other change (a new upstream, a cert rotation) the
376+
// same reconcile made to that route — the operator's `updateConfig` reports success while
377+
// their edit quietly never took effect.
378+
if let Err(e) = validate_route_protocol_declaration(spec) {
379+
let newly_failing = previous.is_none_or(|p| !p.failing_snis.contains(spec.sni.as_str()));
380+
failing_snis.insert(Arc::from(spec.sni.as_str()));
381+
if newly_failing {
382+
eprintln!("symphony: skipping route '{}': {}", spec.sni, e);
383+
}
384+
if let Some(suffix) = spec.sni.strip_prefix("*.") {
385+
dropped_wildcard.push(Arc::from(suffix));
386+
} else {
387+
dropped_exact.insert(Arc::from(spec.sni.as_str()));
388+
}
389+
continue;
390+
}
391+
362392
// Isolate per-route failures: a single route whose cert can't be built (e.g. a
363393
// rotated key no longer matching an inlined chain → rustls KeyMismatch) must not
364394
// abort the whole table and take every other tenant on the port down with it.
@@ -429,19 +459,16 @@ pub fn build_route_table(
429459
Ok(RouteTable { exact, wildcard, default: None, monitored_balancers, failing_snis, dropped_exact, dropped_wildcard })
430460
}
431461

432-
fn build_route(
433-
spec: &RouteSpec,
434-
listener_tls: &ListenerTlsSpec,
435-
cache: &mut TlsConfigCache,
436-
) -> crate::error::Result<Route> {
462+
/// The "no carrier" and "declare protocol: 'http'" requirements — checked in `build_route_table`
463+
/// before `build_route` runs, and deliberately never carried forward on a hot-swap (see the call
464+
/// site): unlike a cert-build failure, this is a permanent config error, not a transient race.
465+
fn validate_route_protocol_declaration(spec: &RouteSpec) -> crate::error::Result<()> {
437466
let requires_http = requires_http_protocol(spec.source_address_mode, spec.forward_fingerprint);
438467

439468
// Passthrough (terminateTls=false) never decrypts the stream, so a header-carried mode has no
440469
// carrier at all regardless of `protocol` — declaring 'http' wouldn't help. Checked before (and
441470
// distinct from) the declaration requirement below: it's not that the route mislabeled its
442-
// protocol, it's that no protocol declaration could make this work. Isolated here (rather than
443-
// at parse time in proxy.rs) so one route's misconfiguration is dropped/last-good-carried like
444-
// a bad cert, instead of aborting construction or updateConfig for every other route.
471+
// protocol, it's that no protocol declaration could make this work.
445472
if requires_http && !spec.terminate_tls {
446473
return Err(crate::error::SymphonyError::Config(format!(
447474
"route '{}': sourceAddressHeader 'xForwardedFor', or forwardFingerprint via an HTTP header (any mode other than 'proxyProtocolV2'), has no carrier when terminateTls=false (passthrough never decrypts the stream, so no header can be injected) — use sourceAddressHeader='proxyProtocolV2' (carries both source address and fingerprint), or remove forwardFingerprint",
@@ -456,6 +483,14 @@ fn build_route(
456483
)));
457484
}
458485

486+
Ok(())
487+
}
488+
489+
fn build_route(
490+
spec: &RouteSpec,
491+
listener_tls: &ListenerTlsSpec,
492+
cache: &mut TlsConfigCache,
493+
) -> crate::error::Result<Route> {
459494
let tls_config = if spec.terminate_tls {
460495
let cert_pem = spec
461496
.cert_pem
@@ -811,6 +846,30 @@ UlqL1DcgX6Szi9w/p7B4BZO9iA==
811846
);
812847
}
813848

849+
// The inverse and more dangerous case: a dropped wildcard must NOT shadow a healthy, more
850+
// specific exact route at the same suffix. A prior version of the fail-closed check tested
851+
// `dropped_wildcard` before `exact`, which black-holed every exact route under any wildcard
852+
// that happened to fail validation — turning one tenant's wildcard typo into an outage for
853+
// every co-tenant sharing that parent domain.
854+
#[test]
855+
fn healthy_exact_route_survives_a_dropped_sibling_wildcard() {
856+
let specs = vec![
857+
tls_route("*.acme.com", CERT_A, KEY_B), // KeyMismatch — dropped
858+
tls_route("api.acme.com", CERT_A, KEY_A), // valid, more specific
859+
];
860+
861+
let table = build_route_table(&specs, &ListenerTlsSpec::empty(), None).expect("build");
862+
863+
assert!(
864+
table.resolve(Some("api.acme.com")).is_some(),
865+
"a healthy exact route must resolve normally even when a sibling wildcard at the same suffix was dropped"
866+
);
867+
assert!(
868+
table.resolve(Some("other.acme.com")).is_none(),
869+
"a subdomain with no exact route of its own must still fail closed against the dropped wildcard"
870+
);
871+
}
872+
814873
// On a hot-swap, a route whose cert transiently fails to rebuild (the normal cert+key
815874
// non-atomic write window) must retain its last-good route from the live table rather
816875
// than dropping the SNI.
@@ -842,6 +901,31 @@ UlqL1DcgX6Szi9w/p7B4BZO9iA==
842901
);
843902
}
844903

904+
// Unlike a cert-build failure (a transient race that heals on the next reconcile), a
905+
// protocol-declaration/no-carrier rejection is a permanent config error — it never heals
906+
// without a human editing the config. It must therefore never get cert-style last-good
907+
// carry-forward: an operator who accidentally drops `protocol: 'http'` in the same edit that
908+
// repoints a route to a new upstream must see that route stop serving, not have `updateConfig`
909+
// silently keep the old route (old upstream included) running indefinitely.
910+
#[test]
911+
fn permanent_protocol_rejection_never_carries_forward_on_hot_swap() {
912+
let mut good = tls_route("tenant.example.com", CERT_A, KEY_A);
913+
good.source_address_mode = SourceAddressMode::XForwardedFor;
914+
good.protocol = RouteProtocol::Http;
915+
let live = build_route_table(&[good], &ListenerTlsSpec::empty(), None).expect("initial build");
916+
assert!(live.resolve(Some("tenant.example.com")).is_some());
917+
918+
// Hot-swap drops the protocol declaration — a permanent rejection, not a transient one.
919+
let mut broken = tls_route("tenant.example.com", CERT_A, KEY_A);
920+
broken.source_address_mode = SourceAddressMode::XForwardedFor;
921+
// protocol left at its default (Opaque) — undeclared.
922+
let swapped = build_route_table(&[broken], &ListenerTlsSpec::empty(), Some(&live)).expect("hot-swap must not fail");
923+
assert!(
924+
swapped.resolve(Some("tenant.example.com")).is_none(),
925+
"a permanent protocol-declaration rejection must drop the route, not silently carry the previous one forward"
926+
);
927+
}
928+
845929
#[test]
846930
fn header_injection_detection() {
847931
// xForwardedFor always needs protocol: 'http', regardless of fingerprint.

0 commit comments

Comments
 (0)