Skip to content

Commit 4828512

Browse files
committed
Harden Fastly backend naming and transport-timeout quantization
Address the auction transport-timeout review findings: - Derive dynamic backend names from a bounded readable prefix plus a SHA-256 digest of the complete backend spec, so distinct specs never alias to one name. Name equality now implies spec equality, making NameInUse reuse provably safe, and the name is bounded to Fastly's 255-char limit regardless of host or discriminator length. - Bound budget-derived transport-timeout cardinality with a globally finite ladder: keep the 250ms quantum through 2000ms, then snap larger budgets to a small fixed set of coarse rungs so a large configured ceiling can no longer mint hundreds of dynamic backends. - Reject duplicate configured providers at startup, and check the predicted backend name before request_bids fires the outbound send, retaining the post-launch check as defense against a provider that resolves to an unexpected name.
1 parent 5512d85 commit 4828512

5 files changed

Lines changed: 390 additions & 52 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/trusted-server-adapter-fastly/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ log = { workspace = true }
2121
log-fastly = { workspace = true }
2222
serde = { workspace = true }
2323
serde_json = { workspace = true }
24+
sha2 = { workspace = true }
2425
trusted-server-core = { workspace = true }
2526
url = { workspace = true }
2627
urlencoding = { workspace = true }

crates/trusted-server-adapter-fastly/src/backend.rs

Lines changed: 229 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::time::Duration;
22

33
use error_stack::{Report, ResultExt as _};
44
use fastly::backend::Backend;
5+
use sha2::{Digest as _, Sha256};
56
use url::Url;
67

78
use trusted_server_core::error::TrustedServerError;
@@ -47,6 +48,34 @@ fn sanitize_backend_name_component(value: &str) -> String {
4748
.collect()
4849
}
4950

51+
/// Fastly's documented maximum length for a dynamic backend name.
52+
const MAX_BACKEND_NAME_LEN: usize = 255;
53+
/// Maximum length of the human-readable prefix folded into a backend name.
54+
///
55+
/// Bounds the name so that `backend_<prefix>_<digest>` can never exceed
56+
/// [`MAX_BACKEND_NAME_LEN`]: 8 (`backend_`) + 200 + 1 (`_`) +
57+
/// [`SPEC_DIGEST_HEX_LEN`] = 241 ≤ 255.
58+
const MAX_READABLE_PREFIX_LEN: usize = 200;
59+
/// Width of the hex digest suffix — the first 128 bits of a SHA-256 over the
60+
/// full backend spec, which is collision-resistant at the handful-of-hundreds
61+
/// scale of a service's dynamic backends.
62+
const SPEC_DIGEST_HEX_LEN: usize = 32;
63+
64+
/// Hex-encode the first 128 bits of a SHA-256 digest of `canonical`.
65+
///
66+
/// Used to make a backend name a collision-resistant function of the complete
67+
/// backend spec (see [`BackendConfig::canonical_spec_string`]).
68+
fn spec_digest_hex(canonical: &str) -> String {
69+
let mut hasher = Sha256::new();
70+
hasher.update(canonical.as_bytes());
71+
let digest = hasher.finalize();
72+
digest
73+
.iter()
74+
.take(SPEC_DIGEST_HEX_LEN / 2)
75+
.map(|byte| format!("{byte:02x}"))
76+
.collect()
77+
}
78+
5079
/// Default first-byte timeout for backends (15 seconds).
5180
pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15);
5281
/// Default timeout between response body bytes for backends (10 seconds).
@@ -143,17 +172,61 @@ impl<'a> BackendConfig<'a> {
143172
self
144173
}
145174

175+
/// Build an unambiguous, length-prefixed encoding of the complete backend
176+
/// spec for digesting.
177+
///
178+
/// Every field is prefixed with its byte length so that no two distinct
179+
/// specs can encode to the same string (a lossy substitution like
180+
/// `sanitize_backend_name_component` cannot guarantee this). `Option` fields
181+
/// are presence-tagged so a `None` never aliases a `Some("")`. The result is
182+
/// fed to [`spec_digest_hex`]; it is never parsed, only hashed.
183+
fn canonical_spec_string(&self, target_port: u16) -> String {
184+
fn push_field(buf: &mut String, field: &str) {
185+
buf.push_str(&field.len().to_string());
186+
buf.push(':');
187+
buf.push_str(field);
188+
}
189+
190+
let mut buf = String::new();
191+
push_field(&mut buf, self.scheme);
192+
push_field(&mut buf, self.host);
193+
push_field(&mut buf, &target_port.to_string());
194+
push_field(&mut buf, if self.certificate_check { "1" } else { "0" });
195+
match self.host_header_override {
196+
Some(value) => {
197+
buf.push('s');
198+
push_field(&mut buf, value);
199+
}
200+
None => buf.push('n'),
201+
}
202+
match self.discriminator {
203+
Some(value) => {
204+
buf.push('s');
205+
push_field(&mut buf, value);
206+
}
207+
None => buf.push('n'),
208+
}
209+
push_field(&mut buf, &self.first_byte_timeout.as_millis().to_string());
210+
push_field(&mut buf, &self.between_bytes_timeout.as_millis().to_string());
211+
buf
212+
}
213+
146214
/// Compute the deterministic backend name and resolved port without
147215
/// registering anything.
148216
///
149-
/// The name encodes scheme, host, port, certificate setting, optional
150-
/// discriminator, and the first-byte/between-bytes timeouts so that
151-
/// backends with different configurations never collide. Including the
152-
/// timeout prevents "first-registration-wins" poisoning where a later
153-
/// request for the same origin with a tighter timeout would silently
154-
/// inherit the original registration's value. Including the discriminator
155-
/// keeps two callers that target the same origin with the same timeout
156-
/// (e.g. two auction providers behind one gateway) on distinct backends.
217+
/// The name is `backend_<readable>_<digest>`, where `<digest>` is a
218+
/// collision-resistant SHA-256 over an unambiguous encoding of the
219+
/// *complete* backend spec — scheme, host, port, certificate setting, Host
220+
/// override, provider discriminator, and the first-byte/between-bytes
221+
/// timeouts (see [`canonical_spec_string`](Self::canonical_spec_string)).
222+
/// Because distinct specs yield distinct digests, name equality implies spec
223+
/// equality: that is what makes reusing a `NameInUse` backend provably safe,
224+
/// and it prevents "first-registration-wins" poisoning where a later request
225+
/// with a tighter timeout would inherit an earlier registration's value. The
226+
/// `<readable>` half is a lossy, bounded slug carried only for logs — any
227+
/// collision there is harmless because uniqueness comes from the digest. The
228+
/// whole name is bounded to [`MAX_BACKEND_NAME_LEN`] so a long host or
229+
/// discriminator can never produce a name Fastly rejects at registration.
157230
fn compute_name(&self) -> Result<(String, u16), Report<TrustedServerError>> {
158231
if self.host.is_empty() {
159232
return Err(Report::new(TrustedServerError::Proxy {
@@ -198,15 +271,37 @@ impl<'a> BackendConfig<'a> {
198271
.unwrap_or_default();
199272
let first_byte_timeout_ms = self.first_byte_timeout.as_millis();
200273
let between_bytes_timeout_ms = self.between_bytes_timeout.as_millis();
201-
let backend_name = format!(
202-
"backend_{}{}{}{}_fb{}_bb{}",
274+
275+
// Lossy, human-readable slug for logs. Correctness does not depend on
276+
// it — uniqueness comes from the digest below — so it is bounded to a
277+
// fixed length. Sanitization only emits ASCII, so a char-boundary take
278+
// is byte-exact.
279+
let readable_full = format!(
280+
"{}{}{}{}_fb{}_bb{}",
203281
sanitize_backend_name_component(&name_base),
204282
host_override_suffix,
205283
cert_suffix,
206284
discriminator_suffix,
207285
first_byte_timeout_ms,
208286
between_bytes_timeout_ms
209287
);
288+
let readable: String = readable_full.chars().take(MAX_READABLE_PREFIX_LEN).collect();
289+
290+
// Collision-resistant over the *complete* spec, so name equality implies
291+
// spec equality and `NameInUse` reuse is safe.
292+
let digest = spec_digest_hex(&self.canonical_spec_string(target_port));
293+
let backend_name = format!("backend_{readable}_{digest}");
294+
295+
// Bounded by construction; assert it so any future format change fails
296+
// attributably during prediction rather than at Fastly registration.
297+
if backend_name.len() > MAX_BACKEND_NAME_LEN {
298+
return Err(Report::new(TrustedServerError::Proxy {
299+
message: format!(
300+
"backend name exceeds {MAX_BACKEND_NAME_LEN}-char limit ({} chars)",
301+
backend_name.len()
302+
),
303+
}));
304+
}
210305

211306
Ok((backend_name, target_port))
212307
}
@@ -373,7 +468,35 @@ impl<'a> BackendConfig<'a> {
373468

374469
#[cfg(test)]
375470
mod tests {
376-
use super::{compute_host_header, BackendConfig};
471+
use super::{
472+
compute_host_header, BackendConfig, MAX_BACKEND_NAME_LEN, SPEC_DIGEST_HEX_LEN,
473+
};
474+
475+
/// Assert a computed name is `backend_<body>_<hex digest>` and stays within
476+
/// Fastly's length limit. The digest is what makes the name injective, so
477+
/// checking its presence and width guards the collision-safety property.
478+
fn assert_backend_name_shape(name: &str, expected_body: &str) {
479+
let prefix = format!("backend_{expected_body}_");
480+
assert!(
481+
name.starts_with(&prefix),
482+
"name should start with the readable body `{prefix}`, got {name}"
483+
);
484+
let digest = &name[prefix.len()..];
485+
assert_eq!(
486+
digest.len(),
487+
SPEC_DIGEST_HEX_LEN,
488+
"digest suffix should be {SPEC_DIGEST_HEX_LEN} hex chars, got {digest}"
489+
);
490+
assert!(
491+
digest.bytes().all(|byte| byte.is_ascii_hexdigit()),
492+
"digest suffix should be hex, got {digest}"
493+
);
494+
assert!(
495+
name.len() <= MAX_BACKEND_NAME_LEN,
496+
"name should stay within the {MAX_BACKEND_NAME_LEN}-char limit, got {}",
497+
name.len()
498+
);
499+
}
377500

378501
// Tests for compute_host_header - the fix for port preservation in Host header
379502
#[test]
@@ -422,7 +545,7 @@ mod tests {
422545
let name = BackendConfig::new("https", "origin.example.com")
423546
.ensure()
424547
.expect("should create backend for valid HTTPS origin");
425-
assert_eq!(name, "backend_https_origin_example_com_443_fb15000_bb10000");
548+
assert_backend_name_shape(&name, "https_origin_example_com_443_fb15000_bb10000");
426549
}
427550

428551
#[test]
@@ -431,10 +554,7 @@ mod tests {
431554
.certificate_check(false)
432555
.ensure()
433556
.expect("should create backend with cert check disabled");
434-
assert_eq!(
435-
name,
436-
"backend_https_origin_example_com_443_nocert_fb15000_bb10000"
437-
);
557+
assert_backend_name_shape(&name, "https_origin_example_com_443_nocert_fb15000_bb10000");
438558
}
439559

440560
#[test]
@@ -443,15 +563,15 @@ mod tests {
443563
.port(Some(8080))
444564
.ensure()
445565
.expect("should create backend for HTTP origin with explicit port");
446-
assert_eq!(name, "backend_http_api_test-site_org_8080_fb15000_bb10000");
566+
assert_backend_name_shape(&name, "http_api_test-site_org_8080_fb15000_bb10000");
447567
}
448568

449569
#[test]
450570
fn returns_name_for_http_without_port_defaults_to_80() {
451571
let name = BackendConfig::new("http", "example.org")
452572
.ensure()
453573
.expect("should create backend defaulting to port 80 for HTTP");
454-
assert_eq!(name, "backend_http_example_org_80_fb15000_bb10000");
574+
assert_backend_name_shape(&name, "http_example_org_80_fb15000_bb10000");
455575
}
456576

457577
#[test]
@@ -506,13 +626,13 @@ mod tests {
506626
name_a, name_b,
507627
"backends with different host header overrides should have different names"
508628
);
509-
assert_eq!(
510-
name_a,
511-
"backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb10000"
629+
assert_backend_name_shape(
630+
&name_a,
631+
"https_origin_example_com_443_oh_www_example_com_fb15000_bb10000",
512632
);
513-
assert_eq!(
514-
name_b,
515-
"backend_https_origin_example_com_443_oh_m_example_com_fb15000_bb10000"
633+
assert_backend_name_shape(
634+
&name_b,
635+
"https_origin_example_com_443_oh_m_example_com_fb15000_bb10000",
516636
);
517637
}
518638

@@ -567,12 +687,12 @@ mod tests {
567687
"backends with different timeouts should have different names"
568688
);
569689
assert!(
570-
name_a.ends_with("_fb2000_bb10000"),
571-
"name should include first-byte and between-bytes timeout suffix"
690+
name_a.contains("_fb2000_bb10000_"),
691+
"name should include first-byte and between-bytes timeout in the readable body"
572692
);
573693
assert!(
574-
name_b.ends_with("_fb500_bb10000"),
575-
"name should include first-byte and between-bytes timeout suffix"
694+
name_b.contains("_fb500_bb10000_"),
695+
"name should include first-byte and between-bytes timeout in the readable body"
576696
);
577697
}
578698

@@ -594,12 +714,89 @@ mod tests {
594714
"backends with different between-bytes timeouts should have different names"
595715
);
596716
assert!(
597-
name_a.ends_with("_fb15000_bb2000"),
598-
"name should include first-byte and between-bytes timeout suffix"
717+
name_a.contains("_fb15000_bb2000_"),
718+
"name should include first-byte and between-bytes timeout in the readable body"
599719
);
600720
assert!(
601-
name_b.ends_with("_fb15000_bb500"),
602-
"name should include first-byte and between-bytes timeout suffix"
721+
name_b.contains("_fb15000_bb500_"),
722+
"name should include first-byte and between-bytes timeout in the readable body"
723+
);
724+
}
725+
726+
#[test]
727+
fn discriminators_that_sanitize_alike_produce_distinct_names() {
728+
// `provider.a` and `provider_a` both sanitize to the same readable slug
729+
// (`.` maps to `_`). Before the spec digest they collided to one backend
730+
// name, so the second registration silently reused the first — routing
731+
// one provider's auction traffic through another's backend. The digest
732+
// over the raw spec must keep them distinct.
733+
let dotted = BackendConfig::new("https", "gateway.example.com")
734+
.discriminator(Some("provider.a"))
735+
.predict_name()
736+
.expect("should predict name for dotted discriminator");
737+
let underscored = BackendConfig::new("https", "gateway.example.com")
738+
.discriminator(Some("provider_a"))
739+
.predict_name()
740+
.expect("should predict name for underscored discriminator");
741+
assert_ne!(
742+
dotted, underscored,
743+
"discriminators differing only by a sanitized character must not collide"
744+
);
745+
}
746+
747+
#[test]
748+
fn host_overrides_that_sanitize_alike_produce_distinct_names() {
749+
// `host.example.com:8443` (host+port) and `host.example.com.8443` (DNS
750+
// label) are both valid overrides that sanitize to the same readable
751+
// slug (`:` and `.` both map to `_`). The digest over the raw value must
752+
// keep the two backends — with different Host routing — distinct.
753+
let with_port = BackendConfig::new("https", "origin.example.com")
754+
.host_header_override(Some("host.example.com:8443"))
755+
.predict_name()
756+
.expect("should predict name for host:port override");
757+
let with_label = BackendConfig::new("https", "origin.example.com")
758+
.host_header_override(Some("host.example.com.8443"))
759+
.predict_name()
760+
.expect("should predict name for dotted-label override");
761+
assert_ne!(
762+
with_port, with_label,
763+
"host overrides differing only by a sanitized character must not collide"
764+
);
765+
}
766+
767+
#[test]
768+
fn long_host_and_discriminator_stay_within_the_length_limit() {
769+
// A syntactically valid maximum-length DNS host plus a discriminator
770+
// previously pushed the name past Fastly's 255-char limit, so
771+
// `predict_name` succeeded while `ensure` failed at registration. The
772+
// bounded prefix + fixed-width digest must keep prediction, and the name
773+
// it predicts, within the limit.
774+
let label = "a".repeat(63);
775+
let long_host = format!("{label}.{label}.{label}.{label}.example.com");
776+
assert!(
777+
long_host.len() > 200,
778+
"should exercise a host longer than the readable-prefix bound"
779+
);
780+
let name = BackendConfig::new("https", &long_host)
781+
.discriminator(Some("prebid"))
782+
.predict_name()
783+
.expect("should predict a bounded name for a long host and discriminator");
784+
assert!(
785+
name.len() <= MAX_BACKEND_NAME_LEN,
786+
"name should stay within the {MAX_BACKEND_NAME_LEN}-char limit, got {}",
787+
name.len()
788+
);
789+
790+
// Two long hosts sharing the truncated prefix must still resolve to
791+
// different backends via the digest.
792+
let other_host = format!("{label}.{label}.{label}.{label}.example.net");
793+
let other = BackendConfig::new("https", &other_host)
794+
.discriminator(Some("prebid"))
795+
.predict_name()
796+
.expect("should predict a bounded name for the sibling host");
797+
assert_ne!(
798+
name, other,
799+
"hosts sharing a truncated prefix must stay distinct via the digest"
603800
);
604801
}
605802
}

0 commit comments

Comments
 (0)