Skip to content
27 changes: 4 additions & 23 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,7 @@ fn main() {
log::debug!("Settings {settings:?}");

// Build the auction orchestrator once at startup
let orchestrator = match build_orchestrator(&settings) {
Ok(o) => o,
Err(e) => {
log::error!("Failed to build auction orchestrator: {:?}", e);
to_error_response(&e).send_to_client();
return;
}
};
let orchestrator = build_orchestrator(&settings);

let integration_registry = match IntegrationRegistry::new(&settings) {
Ok(r) => r,
Expand Down Expand Up @@ -107,21 +100,9 @@ async fn route_request(
// Extract geo info before auth check or routing consumes the request
let geo_info = GeoInfo::from_request(&req);

// `get_settings()` should already have rejected invalid handler regexes.
// Keep this fallback so manually-constructed or otherwise unprepared
// settings still become an error response instead of panicking.
match enforce_basic_auth(settings, &req) {
Ok(Some(mut response)) => {
finalize_response(settings, geo_info.as_ref(), &mut response);
return Some(response);
}
Ok(None) => {}
Err(e) => {
log::error!("Failed to evaluate basic auth: {:?}", e);
let mut response = to_error_response(&e);
finalize_response(settings, geo_info.as_ref(), &mut response);
return Some(response);
}
if let Some(mut response) = enforce_basic_auth(settings, &req) {
finalize_response(settings, geo_info.as_ref(), &mut response);
return Some(response);
}

// Get path and method for routing
Expand Down
5 changes: 5 additions & 0 deletions crates/trusted-server-core/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ fn main() {

// Merge base TOML with environment variable overrides and write output.
// Panics if admin endpoints are not covered by a handler.
// Note: placeholder secret rejection is intentionally NOT done here.
// The base trusted-server.toml ships with placeholder secrets that
// production deployments override via TRUSTED_SERVER__* env vars at
// build time. Runtime startup (get_settings) rejects any remaining
// placeholders so a misconfigured deployment fails fast.
let settings = settings::Settings::from_toml_and_env(&toml_content)
.expect("Failed to parse settings at build time");

Expand Down
19 changes: 5 additions & 14 deletions crates/trusted-server-core/src/auction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
//! Note: Individual auction providers are located in the `integrations` module
//! (e.g., `crate::integrations::aps`, `crate::integrations::prebid`).

use error_stack::Report;

use crate::error::TrustedServerError;
use crate::settings::Settings;
use std::sync::Arc;

Expand All @@ -30,8 +27,7 @@ pub use types::{
};

/// Type alias for provider builder functions.
type ProviderBuilder =
fn(&Settings) -> Result<Vec<Arc<dyn AuctionProvider>>, Report<TrustedServerError>>;
type ProviderBuilder = fn(&Settings) -> Vec<Arc<dyn AuctionProvider>>;

/// Returns the list of all available provider builder functions.
///
Expand All @@ -53,20 +49,15 @@ fn provider_builders() -> &'static [ProviderBuilder] {
///
/// # Arguments
/// * `settings` - Application settings used to configure the orchestrator and providers
///
/// # Errors
///
/// Returns an error when an enabled auction provider has invalid configuration.
pub fn build_orchestrator(
settings: &Settings,
) -> Result<AuctionOrchestrator, Report<TrustedServerError>> {
#[must_use]
pub fn build_orchestrator(settings: &Settings) -> AuctionOrchestrator {
log::info!("Building auction orchestrator");

let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone());

// Auto-discover and register all auction providers from settings
for builder in provider_builders() {
for provider in builder(settings)? {
for provider in builder(settings) {
orchestrator.register_provider(provider);
}
}
Expand All @@ -76,5 +67,5 @@ pub fn build_orchestrator(
orchestrator.provider_count()
);

Ok(orchestrator)
orchestrator
}
77 changes: 25 additions & 52 deletions crates/trusted-server-core/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use error_stack::Report;
use fastly::http::{header, StatusCode};
use fastly::{Request, Response};
use sha2::{Digest as _, Sha256};
use subtle::ConstantTimeEq as _;

use crate::error::TrustedServerError;
use crate::settings::Settings;

const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#;

/// Enforce HTTP basic auth for the matched handler, if any.
/// Enforces Basic-auth for incoming requests.
///
/// Returns `Ok(None)` when the request does not target a protected handler or
/// when the supplied credentials are valid. Returns `Ok(Some(Response))` with
/// the auth challenge when credentials are missing or invalid.
/// Authentication is required when a configured handler's `path` regex matches
/// the request path. Paths not covered by any handler pass through without
/// authentication.
///
/// # Errors
/// Admin endpoints are protected by requiring a handler at build time — see
/// [`Settings::from_toml_and_env`].
///
/// Returns an error when handler configuration is invalid, such as an
/// un-compilable path regex.
pub fn enforce_basic_auth(
settings: &Settings,
req: &Request,
) -> Result<Option<Response>, Report<TrustedServerError>> {
let Some(handler) = settings.handler_for_path(req.get_path())? else {
return Ok(None);
};
/// # Returns
///
/// * `Some(Response)` — a `401 Unauthorized` response that should be sent back
/// to the client (credentials missing or incorrect).
/// * `None` — the request is allowed to proceed.
pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option<Response> {
let path = req.get_path();

let handler = settings.handler_for_path(path)?;

let (username, password) = match extract_credentials(req) {
Some(credentials) => credentials,
None => return Ok(Some(unauthorized_response())),
None => return Some(unauthorized_response()),
};

// Hash before comparing to normalise lengths — `ct_eq` on raw byte slices
Expand All @@ -46,9 +45,9 @@ pub fn enforce_basic_auth(
.ct_eq(&Sha256::digest(password.as_bytes()));

if bool::from(username_match & password_match) {
Ok(None)
None
} else {
Ok(Some(unauthorized_response()))
Some(unauthorized_response())
}
}

Expand Down Expand Up @@ -91,26 +90,22 @@ mod tests {
use base64::engine::general_purpose::STANDARD;
use fastly::http::{header, Method};

use crate::test_support::tests::{crate_test_settings_str, create_test_settings};
use crate::test_support::tests::create_test_settings;

#[test]
fn no_challenge_for_non_protected_path() {
let settings = create_test_settings();
let req = Request::new(Method::GET, "https://example.com/open");

assert!(enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.is_none());
assert!(enforce_basic_auth(&settings, &req).is_none());
}

#[test]
fn challenge_when_missing_credentials() {
let settings = create_test_settings();
let req = Request::new(Method::GET, "https://example.com/secure");

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
let response = enforce_basic_auth(&settings, &req).expect("should challenge");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
let realm = response
.get_header(header::WWW_AUTHENTICATE)
Expand All @@ -125,9 +120,7 @@ mod tests {
let token = STANDARD.encode("user:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

assert!(enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.is_none());
assert!(enforce_basic_auth(&settings, &req).is_none());
}

#[test]
Expand All @@ -138,8 +131,7 @@ mod tests {
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
.expect("should challenge when both username and password are wrong");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

Expand All @@ -149,23 +141,10 @@ mod tests {
let mut req = Request::new(Method::GET, "https://example.com/secure");
req.set_header(header::AUTHORIZATION, "Bearer token");

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
let response = enforce_basic_auth(&settings, &req).expect("should challenge");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn returns_error_for_invalid_handler_regex_without_panicking() {
let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "(""#);
let err = Settings::from_toml(&config).expect_err("should reject invalid handler regex");
assert!(
err.to_string()
.contains("Handler path regex `(` failed to compile"),
"should describe the invalid handler regex"
);
}

#[test]
fn allow_admin_path_with_valid_credentials() {
let settings = create_test_settings();
Expand All @@ -174,9 +153,7 @@ mod tests {
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

assert!(
enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.is_none(),
enforce_basic_auth(&settings, &req).is_none(),
"should allow admin path with correct credentials"
);
}
Expand All @@ -189,7 +166,6 @@ mod tests {
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge admin path with wrong credentials");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}
Expand All @@ -200,7 +176,6 @@ mod tests {
let req = Request::new(Method::POST, "https://example.com/admin/keys/rotate");

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge admin path with missing credentials");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}
Expand All @@ -213,7 +188,6 @@ mod tests {
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge when only username is wrong");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}
Expand All @@ -226,7 +200,6 @@ mod tests {
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge when only password is wrong");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}
Expand Down
6 changes: 0 additions & 6 deletions crates/trusted-server-core/src/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@ fn is_allowed_synthetic_id_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
}

// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a
// defense-in-depth backstop when setting the Set-Cookie header. This is
// intentionally broader than the inbound format validator
// (`synthetic::is_valid_synthetic_id`), which enforces the exact
// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted
// request values before they enter the system.
#[must_use]
pub(crate) fn synthetic_id_has_only_allowed_chars(synthetic_id: &str) -> bool {
synthetic_id.chars().all(is_allowed_synthetic_id_char)
Expand Down
12 changes: 7 additions & 5 deletions crates/trusted-server-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ pub enum TrustedServerError {
#[display("GDPR consent error: {message}")]
GdprConsent { message: String },

/// A configuration secret is still set to a known placeholder value.
#[display(
"Configuration field '{field}' is set to a known placeholder value - this is insecure"
)]
InsecureDefault { field: String },

/// Invalid UTF-8 data encountered.
#[display("Invalid UTF-8 data: {message}")]
InvalidUtf8 { message: String },
Expand Down Expand Up @@ -60,10 +66,6 @@ pub enum TrustedServerError {
#[display("Proxy error: {message}")]
Proxy { message: String },

/// A redirect destination was blocked by the proxy allowlist.
#[display("Redirect to `{host}` blocked: host not in proxy allowed_domains")]
AllowlistViolation { host: String },

/// Settings parsing or validation failed.
#[display("Settings error: {message}")]
Settings { message: String },
Expand Down Expand Up @@ -97,13 +99,13 @@ impl IntoHttpResponse for TrustedServerError {
Self::Configuration { .. } | Self::Settings { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::Gam { .. } => StatusCode::BAD_GATEWAY,
Self::GdprConsent { .. } => StatusCode::BAD_REQUEST,
Self::InsecureDefault { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST,
Self::InvalidUtf8 { .. } => StatusCode::BAD_REQUEST,
Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
Self::Integration { .. } => StatusCode::BAD_GATEWAY,
Self::Proxy { .. } => StatusCode::BAD_GATEWAY,
Self::AllowlistViolation { .. } => StatusCode::FORBIDDEN,
Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
Expand Down
12 changes: 1 addition & 11 deletions crates/trusted-server-core/src/html_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
}),
];

let has_script_rewriters = !script_rewriters.is_empty();
for script_rewriter in script_rewriters {
let selector = script_rewriter.selector();
let rewriter = script_rewriter.clone();
Expand Down Expand Up @@ -493,16 +492,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
..RewriterSettings::default()
};

// Use buffered mode when script rewriters are registered. lol_html fragments
// text nodes across input chunk boundaries, breaking rewriters that expect
// complete text (e.g., __NEXT_DATA__, GTM). Buffered mode feeds the entire
// document in one write() call, preserving text node integrity.
// Phase 3 will make rewriters fragment-safe, enabling streaming for all configs.
let inner = if has_script_rewriters {
HtmlRewriterAdapter::new_buffered(rewriter_settings)
} else {
HtmlRewriterAdapter::new(rewriter_settings)
};
let inner = HtmlRewriterAdapter::new(rewriter_settings);

HtmlWithPostProcessing {
inner,
Expand Down
Loading
Loading