Skip to content

Commit b17a42c

Browse files
Add publisher origin Host header override
1 parent e5093dc commit b17a42c

8 files changed

Lines changed: 513 additions & 10 deletions

File tree

crates/trusted-server-core/build.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ mod redacted;
1515
#[path = "src/consent_config.rs"]
1616
mod consent_config;
1717

18+
#[path = "src/host_header.rs"]
19+
mod host_header;
20+
1821
#[path = "src/settings.rs"]
1922
mod settings;
2023

crates/trusted-server-core/src/backend.rs

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use fastly::backend::Backend;
55
use url::Url;
66

77
use crate::error::TrustedServerError;
8+
use crate::host_header::validate_host_header_override_value;
89

910
/// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP).
1011
#[inline]
@@ -33,6 +34,19 @@ fn compute_host_header(scheme: &str, host: &str, port: u16) -> String {
3334
}
3435
}
3536

37+
fn sanitize_backend_name_component(value: &str) -> String {
38+
value
39+
.chars()
40+
.map(|ch| {
41+
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
42+
ch
43+
} else {
44+
'_'
45+
}
46+
})
47+
.collect()
48+
}
49+
3650
/// Default first-byte timeout for backends (15 seconds).
3751
pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15);
3852

@@ -46,6 +60,7 @@ pub struct BackendConfig<'a> {
4660
port: Option<u16>,
4761
certificate_check: bool,
4862
first_byte_timeout: Duration,
63+
host_header_override: Option<&'a str>,
4964
}
5065

5166
impl<'a> BackendConfig<'a> {
@@ -61,6 +76,7 @@ impl<'a> BackendConfig<'a> {
6176
port: None,
6277
certificate_check: true,
6378
first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT,
79+
host_header_override: None,
6480
}
6581
}
6682

@@ -90,6 +106,13 @@ impl<'a> BackendConfig<'a> {
90106
self
91107
}
92108

109+
/// Set the outbound Host header sent to the backend origin.
110+
#[must_use]
111+
pub fn host_header_override(mut self, host: Option<&'a str>) -> Self {
112+
self.host_header_override = host;
113+
self
114+
}
115+
93116
/// Compute the deterministic backend name and resolved port without
94117
/// registering anything.
95118
///
@@ -114,21 +137,33 @@ impl<'a> BackendConfig<'a> {
114137
message: "scheme contains control characters".to_string(),
115138
}));
116139
}
140+
if let Some(host_header_override) = self.host_header_override {
141+
validate_host_header_override_value(host_header_override).map_err(|reason| {
142+
Report::new(TrustedServerError::Proxy {
143+
message: format!("host header override {reason}"),
144+
})
145+
})?;
146+
}
117147

118148
let target_port = self
119149
.port
120150
.unwrap_or_else(|| default_port_for_scheme(self.scheme));
121151

122152
let name_base = format!("{}_{}_{}", self.scheme, self.host, target_port);
153+
let host_override_suffix = self
154+
.host_header_override
155+
.map(|host| format!("_oh_{}", sanitize_backend_name_component(host)))
156+
.unwrap_or_default();
123157
let cert_suffix = if self.certificate_check {
124158
""
125159
} else {
126160
"_nocert"
127161
};
128162
let timeout_ms = self.first_byte_timeout.as_millis();
129163
let backend_name = format!(
130-
"backend_{}{}_t{}",
131-
name_base.replace(['.', ':'], "_"),
164+
"backend_{}{}{}_t{}",
165+
sanitize_backend_name_component(&name_base),
166+
host_override_suffix,
132167
cert_suffix,
133168
timeout_ms
134169
);
@@ -165,7 +200,10 @@ impl<'a> BackendConfig<'a> {
165200

166201
let host_with_port = format!("{}:{}", self.host, target_port);
167202

168-
let host_header = compute_host_header(self.scheme, self.host, target_port);
203+
let host_header = self
204+
.host_header_override
205+
.map(str::to_owned)
206+
.unwrap_or_else(|| compute_host_header(self.scheme, self.host, target_port));
169207

170208
// Target base is host[:port]; SSL is enabled only for https scheme
171209
let mut builder = Backend::builder(&backend_name, &host_with_port)
@@ -219,6 +257,7 @@ impl<'a> BackendConfig<'a> {
219257
///
220258
/// Centralises URL parsing so that [`from_url`](Self::from_url),
221259
/// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout),
260+
/// [`from_url_with_host_header_override`](Self::from_url_with_host_header_override),
222261
/// and [`backend_name_for_url`](Self::backend_name_for_url) share one
223262
/// code-path.
224263
fn parse_origin(
@@ -278,13 +317,47 @@ impl<'a> BackendConfig<'a> {
278317
origin_url: &str,
279318
certificate_check: bool,
280319
first_byte_timeout: Duration,
320+
) -> Result<String, Report<TrustedServerError>> {
321+
Self::from_url_with_first_byte_timeout_and_host_header_override(
322+
origin_url,
323+
certificate_check,
324+
first_byte_timeout,
325+
None,
326+
)
327+
}
328+
329+
/// Parse an origin URL and ensure a dynamic backend with a custom Host header.
330+
///
331+
/// # Errors
332+
///
333+
/// Returns an error if the URL cannot be parsed or lacks a host, or if
334+
/// backend creation fails.
335+
pub fn from_url_with_host_header_override(
336+
origin_url: &str,
337+
certificate_check: bool,
338+
host_header_override: Option<&str>,
339+
) -> Result<String, Report<TrustedServerError>> {
340+
Self::from_url_with_first_byte_timeout_and_host_header_override(
341+
origin_url,
342+
certificate_check,
343+
DEFAULT_FIRST_BYTE_TIMEOUT,
344+
host_header_override,
345+
)
346+
}
347+
348+
fn from_url_with_first_byte_timeout_and_host_header_override(
349+
origin_url: &str,
350+
certificate_check: bool,
351+
first_byte_timeout: Duration,
352+
host_header_override: Option<&str>,
281353
) -> Result<String, Report<TrustedServerError>> {
282354
let (scheme, host, port) = Self::parse_origin(origin_url)?;
283355

284356
BackendConfig::new(&scheme, &host)
285357
.port(port)
286358
.certificate_check(certificate_check)
287359
.first_byte_timeout(first_byte_timeout)
360+
.host_header_override(host_header_override)
288361
.ensure()
289362
}
290363

@@ -437,6 +510,65 @@ mod tests {
437510
);
438511
}
439512

513+
#[test]
514+
fn host_header_overrides_produce_different_names() {
515+
let (name_a, _) = BackendConfig::new("https", "origin.example.com")
516+
.host_header_override(Some("www.example.com"))
517+
.compute_name()
518+
.expect("should compute name with host header override");
519+
let (name_b, _) = BackendConfig::new("https", "origin.example.com")
520+
.host_header_override(Some("m.example.com"))
521+
.compute_name()
522+
.expect("should compute name with different host header override");
523+
524+
assert_ne!(
525+
name_a, name_b,
526+
"backends with different host header overrides should have different names"
527+
);
528+
assert_eq!(
529+
name_a,
530+
"backend_https_origin_example_com_443_oh_www_example_com_t15000"
531+
);
532+
assert_eq!(
533+
name_b,
534+
"backend_https_origin_example_com_443_oh_m_example_com_t15000"
535+
);
536+
}
537+
538+
#[test]
539+
fn host_header_override_rejects_control_characters() {
540+
let err = BackendConfig::new("https", "origin.example.com")
541+
.host_header_override(Some("www\n.example.com"))
542+
.predict_name()
543+
.expect_err("should reject host header override containing newline");
544+
545+
assert!(
546+
err.to_string().contains("control characters"),
547+
"should report control characters in error message"
548+
);
549+
}
550+
551+
#[test]
552+
fn host_header_override_rejects_invalid_values() {
553+
for host_header_override in [
554+
"https://www.example.com",
555+
"www.example.com/path",
556+
"www.example.com:",
557+
"example..com",
558+
"-",
559+
] {
560+
let err = BackendConfig::new("https", "origin.example.com")
561+
.host_header_override(Some(host_header_override))
562+
.predict_name()
563+
.expect_err("should reject invalid host header override");
564+
565+
assert!(
566+
err.to_string().contains("host header override"),
567+
"should report host header override error for {host_header_override:?}"
568+
);
569+
}
570+
}
571+
440572
#[test]
441573
fn different_timeouts_produce_different_names() {
442574
use std::time::Duration;

0 commit comments

Comments
 (0)