Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ debug = 1
async-trait = "0.1"
base64 = "0.22"
brotli = "8.0"
build-print = "1.0.1"
bytes = "1.11"
chacha20poly1305 = "0.10"
chrono = "0.4.42"
Expand Down
4 changes: 2 additions & 2 deletions SEQUENCE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 🛡️ Trusted Server — First-Party Proxying Flow
# 🛡️ Trusted Server — Proxying Flow

## 🔄 System Flow Diagram

Expand Down Expand Up @@ -80,7 +80,7 @@ sequenceDiagram
activate TS
activate PBS
activate DSP
JS->>TS: GET /first-party/ad<br/>(with signals)
JS->>TS: GET /ad/render<br/>(with signals)
TS->>PBS: POST /openrtb2/auction<br/>(OpenRTB 2.x)
PBS->>DSP: POST bid request
DSP-->>PBS: 200 bid response
Expand Down
2 changes: 1 addition & 1 deletion crates/common/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# trusted-server-common

Utilities shared by Trusted Server components. This crate contains HTML/CSS rewriting helpers used to normalize ad creative assets to first‑party proxy endpoints.
Utilities shared by Trusted Server components. This crate contains HTML/CSS rewriting helpers used to normalize ad creative assets to proxy endpoints.

## Creative Rewriting

Expand Down
124 changes: 108 additions & 16 deletions crates/common/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,42 @@ use url::Url;

use crate::error::TrustedServerError;

/// Compute the Host header value for a backend request.
///
/// For standard ports (443 for HTTPS, 80 for HTTP), returns just the hostname.
/// For non-standard ports, returns "hostname:port" to ensure backends that
/// generate URLs based on the Host header include the port.
///
/// This fixes the issue where backends behind reverse proxies (like Caddy)
/// would generate URLs without the port when the Host header didn't include it.
#[inline]
fn compute_host_header(scheme: &str, host: &str, port: u16) -> String {
let is_https = scheme.eq_ignore_ascii_case("https");
let default_port = if is_https { 443 } else { 80 };
if port != default_port {
format!("{}:{}", host, port)
} else {
host.to_string()
}
}

/// Ensure a dynamic backend exists for the given origin and return its name.
///
/// The backend name is derived from the scheme and `host[:port]` to avoid collisions across
/// http/https or different ports. If a backend with the derived name already exists,
/// this function logs and reuses it.
///
/// # Arguments
///
/// * `scheme` - The URL scheme ("http" or "https")
/// * `host` - The hostname
/// * `port` - Optional port number
/// * `certificate_check` - If true, enables TLS certificate verification (default for production)
pub fn ensure_origin_backend(
scheme: &str,
host: &str,
port: Option<u16>,
certificate_check: bool,
) -> Result<String, Report<TrustedServerError>> {
if host.is_empty() {
return Err(Report::new(TrustedServerError::Proxy {
Expand All @@ -32,20 +59,33 @@ pub fn ensure_origin_backend(
let host_with_port = format!("{}:{}", host, target_port);

// Name: iframe_<scheme>_<host>_<port> (sanitize '.' and ':')
// Include cert setting in name to avoid reusing a backend with different cert settings
let name_base = format!("{}_{}_{}", scheme, host, target_port);
let backend_name = format!("backend_{}", name_base.replace(['.', ':'], "_"));
let cert_suffix = if certificate_check { "" } else { "_nocert" };
let backend_name = format!(
"backend_{}{}",
name_base.replace(['.', ':'], "_"),
cert_suffix
);

let host_header = compute_host_header(scheme, host, target_port);

// Target base is host[:port]; SSL is enabled only for https scheme
let mut builder = Backend::builder(&backend_name, &host_with_port)
.override_host(host)
.override_host(&host_header)
.connect_timeout(Duration::from_secs(1))
.first_byte_timeout(Duration::from_secs(15))
.between_bytes_timeout(Duration::from_secs(10));
if scheme.eq_ignore_ascii_case("https") {
builder = builder
.enable_ssl()
.sni_hostname(host)
.check_certificate(host);
builder = builder.enable_ssl().sni_hostname(host);
if certificate_check {
builder = builder.check_certificate(host);
} else {
log::warn!(
"INSECURE: certificate check disabled for backend: {}",
backend_name
);
}
log::info!("enable ssl for backend: {}", backend_name);
}

Expand Down Expand Up @@ -75,7 +115,10 @@ pub fn ensure_origin_backend(
}
}

pub fn ensure_backend_from_url(origin_url: &str) -> Result<String, Report<TrustedServerError>> {
pub fn ensure_backend_from_url(
origin_url: &str,
certificate_check: bool,
) -> Result<String, Report<TrustedServerError>> {
let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy {
message: format!("Invalid origin_url: {}", origin_url),
})?;
Expand All @@ -88,44 +131,93 @@ pub fn ensure_backend_from_url(origin_url: &str) -> Result<String, Report<Truste
})?;
let port = parsed_url.port();

ensure_origin_backend(scheme, host, port)
ensure_origin_backend(scheme, host, port, certificate_check)
}

#[cfg(test)]
mod tests {
use super::ensure_origin_backend;
use super::{compute_host_header, ensure_origin_backend};

// Tests for compute_host_header - the fix for port preservation in Host header
#[test]
fn returns_name_for_https_no_port() {
let name = ensure_origin_backend("https", "origin.example.com", None).unwrap();
fn host_header_includes_port_for_non_standard_https() {
// Non-standard port 9443 should be included in Host header
assert_eq!(
compute_host_header("https", "cdn.example.com", 9443),
"cdn.example.com:9443"
);
assert_eq!(
compute_host_header("https", "cdn.example.com", 8443),
"cdn.example.com:8443"
);
}

#[test]
fn host_header_excludes_port_for_standard_https() {
// Standard port 443 should NOT be included
assert_eq!(
compute_host_header("https", "cdn.example.com", 443),
"cdn.example.com"
);
}

#[test]
fn host_header_includes_port_for_non_standard_http() {
// Non-standard port 8080 should be included
assert_eq!(
compute_host_header("http", "cdn.example.com", 8080),
"cdn.example.com:8080"
);
}

#[test]
fn host_header_excludes_port_for_standard_http() {
// Standard port 80 should NOT be included
assert_eq!(
compute_host_header("http", "cdn.example.com", 80),
"cdn.example.com"
);
}

#[test]
fn returns_name_for_https_with_cert_check() {
let name = ensure_origin_backend("https", "origin.example.com", None, true).unwrap();
assert_eq!(name, "backend_https_origin_example_com_443");
}

#[test]
fn returns_name_for_https_without_cert_check() {
let name = ensure_origin_backend("https", "origin.example.com", None, false).unwrap();
assert_eq!(name, "backend_https_origin_example_com_443_nocert");
}

#[test]
fn returns_name_for_http_with_port_and_sanitizes() {
let name = ensure_origin_backend("http", "api.test-site.org", Some(8080)).unwrap();
let name = ensure_origin_backend("http", "api.test-site.org", Some(8080), true).unwrap();
assert_eq!(name, "backend_http_api_test-site_org_8080");
// Explicitly check that ':' was replaced with '_'
assert!(name.ends_with("_8080"));
}

#[test]
fn returns_name_for_http_without_port_defaults_to_80() {
let name = ensure_origin_backend("http", "example.org", None).unwrap();
let name = ensure_origin_backend("http", "example.org", None, true).unwrap();
assert_eq!(name, "backend_http_example_org_80");
}

#[test]
fn error_on_missing_host() {
let err = ensure_origin_backend("https", "", None).err().unwrap();
let err = ensure_origin_backend("https", "", None, true)
.err()
.unwrap();
let msg = err.to_string();
assert!(msg.contains("missing host"));
}

#[test]
fn second_call_reuses_existing_backend() {
let first = ensure_origin_backend("https", "reuse.example.com", None).unwrap();
let second = ensure_origin_backend("https", "reuse.example.com", None).unwrap();
let first = ensure_origin_backend("https", "reuse.example.com", None, true).unwrap();
let second = ensure_origin_backend("https", "reuse.example.com", None, true).unwrap();
assert_eq!(first, second);
}
}
42 changes: 41 additions & 1 deletion crates/common/src/creative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ fn build_signed_url_for(
pairs.extend(extra.iter().cloned());
}

// Build tsurl from parsed URL without query/fragment (preserves port)
u.set_query(None);
u.set_fragment(None);
let tsurl = u.as_str().to_string();
let tsurl = u.to_string();

let full_for_token = if pairs.is_empty() {
tsurl.clone()
Expand Down Expand Up @@ -323,7 +324,9 @@ pub fn rewrite_creative_html(markup: &str, settings: &Settings) -> String {
// Image src + data-src
element!("img", |el| {
if let Some(src) = el.get_attribute("src") {
log::debug!("creative rewrite: img src input = {}", src);
if let Some(p) = proxy_if_abs(settings, &src) {
log::debug!("creative rewrite: img src output = {}", p);
let _ = el.set_attribute("src", &p);
}
}
Expand Down Expand Up @@ -539,6 +542,43 @@ mod tests {
assert_eq!(to_abs("mailto:test@example.com", &settings), None);
}

#[test]
fn to_abs_preserves_port_in_protocol_relative() {
let settings = crate::test_support::tests::create_test_settings();
// Protocol-relative URL with explicit port should preserve the port
assert_eq!(
to_abs("//cdn.example.com:8080/asset.js", &settings),
Some("https://cdn.example.com:8080/asset.js".to_string())
);
assert_eq!(
to_abs("//cdn.example.com:9443/img.png", &settings),
Some("https://cdn.example.com:9443/img.png".to_string())
);
}

#[test]
fn rewrite_creative_preserves_non_standard_port() {
// Verify creative rewriting preserves non-standard ports in URLs
let settings = crate::test_support::tests::create_test_settings();
let html = r#"<!DOCTYPE html>
<html>
<body>
<a href="//cdn.example.com:9443/click">
<img src="//cdn.example.com:9443/img/300x250.svg" />
</a>
<img src="//cdn.example.com:9443/pixel?pid=test" width="1" height="1" />
</body>
</html>"#;
let out = rewrite_creative_html(html, &settings);

// Port 9443 should be preserved (URL-encoded as %3A9443)
assert!(
out.contains("cdn.example.com%3A9443"),
"Port 9443 should be preserved in rewritten URLs: {}",
out
);
}

#[test]
fn rewrite_style_urls_handles_absolute_and_relative() {
let settings = crate::test_support::tests::create_test_settings();
Expand Down
2 changes: 1 addition & 1 deletion crates/common/src/fastly_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl FastlyApiClient {
}

pub fn from_secret_store(store_name: &str, key_name: &str) -> Result<Self, TrustedServerError> {
ensure_backend_from_url("https://api.fastly.com").map_err(|e| {
ensure_backend_from_url("https://api.fastly.com", true).map_err(|e| {
TrustedServerError::Configuration {
message: format!("Failed to ensure API backend: {}", e),
}
Expand Down
Loading