Skip to content
Open
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
1 change: 1 addition & 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 @@ -75,6 +75,7 @@ log = "0.4.29"
log-fastly = "0.11.12"
lol_html = "2.7.2"
matchit = "0.9"
mime = "0.3"
pin-project-lite = "0.2"
rand = "0.8"
regex = "1.12.3"
Expand Down
10 changes: 6 additions & 4 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ use fastly::{Error, Request, Response};
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator};
use trusted_server_core::auth::enforce_basic_auth;
use trusted_server_core::compat;
use trusted_server_core::constants::{
ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE,
HEADER_X_TS_ENV, HEADER_X_TS_VERSION,
};
use trusted_server_core::error::TrustedServerError;
use trusted_server_core::geo::GeoInfo;
use trusted_server_core::http_util::sanitize_forwarded_headers;
use trusted_server_core::integrations::IntegrationRegistry;
use trusted_server_core::platform::RuntimeServices;
use trusted_server_core::proxy::{
Expand Down Expand Up @@ -106,7 +106,7 @@ async fn route_request(
// Strip client-spoofable forwarded headers at the edge.
// On Fastly this service IS the first proxy — these headers from
// clients are untrusted and can hijack URL rewriting (see #409).
sanitize_forwarded_headers(&mut req);
compat::sanitize_fastly_forwarded_headers(&mut req);

// Look up geo info via the platform abstraction using the client IP
// already captured in RuntimeServices at the entry point.
Expand All @@ -121,8 +121,10 @@ async fn route_request(
// `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)) => {
let auth_req = compat::from_fastly_request_ref(&req);
match enforce_basic_auth(settings, &auth_req) {
Ok(Some(response)) => {
let mut response = compat::to_fastly_response(response);
finalize_response(settings, geo_info.as_ref(), &mut response);
return Ok(response);
}
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ http = { workspace = true }
iab_gpp = { workspace = true }
jose-jwk = { workspace = true }
log = { workspace = true }
mime = { workspace = true }
rand = { workspace = true }
lol_html = { workspace = true }
matchit = { workspace = true }
Expand Down
9 changes: 6 additions & 3 deletions crates/trusted-server-core/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use error_stack::{Report, ResultExt};
use fastly::{Request, Response};

use crate::auction::formats::AdRequest;
use crate::compat;
use crate::consent;
use crate::cookies::handle_request_cookies;
use crate::error::TrustedServerError;
Expand Down Expand Up @@ -46,16 +47,18 @@ pub async fn handle_auction(
body.ad_units.len()
);

let http_req = compat::from_fastly_request_ref(&req);

// Generate synthetic ID early so the consent pipeline can use it for
// KV Store fallback/write operations.
let synthetic_id = get_or_generate_synthetic_id(settings, services, &req).change_context(
let synthetic_id = get_or_generate_synthetic_id(settings, services, &http_req).change_context(
TrustedServerError::Auction {
message: "Failed to generate synthetic ID".to_string(),
},
)?;

// Extract consent from request cookies, headers, and geo.
let cookie_jar = handle_request_cookies(&req)?;
let cookie_jar = handle_request_cookies(&http_req)?;
let geo = services
.geo()
.lookup(services.client_info.client_ip)
Expand All @@ -65,7 +68,7 @@ pub async fn handle_auction(
});
let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput {
jar: cookie_jar.as_ref(),
req: &req,
req: &http_req,
config: &settings.consent,
geo: geo.as_ref(),
synthetic_id: Some(synthetic_id.as_str()),
Expand Down
4 changes: 3 additions & 1 deletion crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::collections::HashMap;
use uuid::Uuid;

use crate::auction::context::ContextValue;
use crate::compat;
use crate::consent::ConsentContext;
use crate::creative;
use crate::error::TrustedServerError;
Expand Down Expand Up @@ -89,7 +90,8 @@ pub fn convert_tsjs_to_auction_request(
geo: Option<GeoInfo>,
) -> Result<AuctionRequest, Report<TrustedServerError>> {
let synthetic_id = synthetic_id.to_owned();
let fresh_id = generate_synthetic_id(settings, services, req).change_context(
let http_req = compat::from_fastly_request_ref(req);
let fresh_id = generate_synthetic_id(settings, services, &http_req).change_context(
TrustedServerError::Auction {
message: "Failed to generate fresh ID".to_string(),
},
Expand Down
98 changes: 59 additions & 39 deletions crates/trusted-server-core/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use edgezero_core::body::Body as EdgeBody;
use error_stack::Report;
use fastly::http::{header, StatusCode};
use fastly::{Request, Response};
use http::header;
use http::{Request, Response, StatusCode};
use sha2::{Digest as _, Sha256};
use subtle::ConstantTimeEq as _;

Expand All @@ -27,9 +28,9 @@ const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#;
/// 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 {
req: &Request<EdgeBody>,
) -> Result<Option<Response<EdgeBody>>, Report<TrustedServerError>> {
let Some(handler) = settings.handler_for_path(req.uri().path())? else {
return Ok(None);
};

Expand All @@ -53,14 +54,15 @@ pub fn enforce_basic_auth(
if bool::from(username_match & password_match) {
Ok(None)
} else {
log::warn!("Basic auth failed for path: {}", req.get_path());
log::warn!("Basic auth failed for path: {}", req.uri().path());
Ok(Some(unauthorized_response()))
}
}

fn extract_credentials(req: &Request) -> Option<(String, String)> {
fn extract_credentials(req: &Request<EdgeBody>) -> Option<(String, String)> {
let header_value = req
.get_header(header::AUTHORIZATION)
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())?;

let mut parts = header_value.splitn(2, ' ');
Expand All @@ -84,25 +86,42 @@ fn extract_credentials(req: &Request) -> Option<(String, String)> {
Some((username, password))
}

fn unauthorized_response() -> Response {
Response::from_status(StatusCode::UNAUTHORIZED)
.with_header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM)
.with_header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.with_body_text_plain("Unauthorized")
fn unauthorized_response() -> Response<EdgeBody> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(EdgeBody::from(b"Unauthorized".as_ref()))
.expect("should build unauthorized response")
}

#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use fastly::http::{header, Method};
use http::{header, HeaderValue, Method};

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

fn build_request(method: Method, uri: &str) -> Request<EdgeBody> {
Request::builder()
.method(method)
.uri(uri)
.body(EdgeBody::empty())
.expect("should build request")
}

fn set_authorization(req: &mut Request<EdgeBody>, value: &str) {
req.headers_mut().insert(
header::AUTHORIZATION,
HeaderValue::from_str(value).expect("should build authorization header"),
);
}

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

assert!(enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
Expand All @@ -112,24 +131,25 @@ mod tests {
#[test]
fn challenge_when_missing_credentials() {
let settings = create_test_settings();
let req = Request::new(Method::GET, "https://example.com/secure");
let req = build_request(Method::GET, "https://example.com/secure");

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let realm = response
.get_header(header::WWW_AUTHENTICATE)
.headers()
.get(header::WWW_AUTHENTICATE)
.expect("should have WWW-Authenticate header");
assert_eq!(realm, BASIC_AUTH_REALM);
}

#[test]
fn allow_when_credentials_match() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let mut req = build_request(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("user:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
set_authorization(&mut req, &format!("Basic {token}"));

assert!(enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
Expand All @@ -139,29 +159,29 @@ mod tests {
#[test]
fn challenge_when_both_credentials_wrong() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let mut req = build_request(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("wrong:wrong");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
set_authorization(&mut req, &format!("Basic {token}"));

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

#[test]
fn challenge_when_username_wrong_password_correct() {
// Validates that both fields are always evaluated — no short-circuit username oracle.
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let mut req = build_request(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("wrong-user:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
set_authorization(&mut req, &format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(
response.get_status(),
response.status(),
StatusCode::UNAUTHORIZED,
"should reject wrong username even with correct password"
);
Expand All @@ -170,15 +190,15 @@ mod tests {
#[test]
fn challenge_when_username_correct_password_wrong() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let mut req = build_request(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("user:wrong-pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
set_authorization(&mut req, &format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(
response.get_status(),
response.status(),
StatusCode::UNAUTHORIZED,
"should reject correct username with wrong password"
);
Expand All @@ -187,13 +207,13 @@ mod tests {
#[test]
fn challenge_when_scheme_is_not_basic() {
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure");
req.set_header(header::AUTHORIZATION, "Bearer token");
let mut req = build_request(Method::GET, "https://example.com/secure");
set_authorization(&mut req, "Bearer token");

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

#[test]
Expand All @@ -210,9 +230,9 @@ mod tests {
#[test]
fn allow_admin_path_with_valid_credentials() {
let settings = create_test_settings();
let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate");
let mut req = build_request(Method::POST, "https://example.com/admin/keys/rotate");
let token = STANDARD.encode("admin:admin-pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
set_authorization(&mut req, &format!("Basic {token}"));

assert!(
enforce_basic_auth(&settings, &req)
Expand All @@ -225,24 +245,24 @@ mod tests {
#[test]
fn challenge_admin_path_with_wrong_credentials() {
let settings = create_test_settings();
let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate");
let mut req = build_request(Method::POST, "https://example.com/admin/keys/rotate");
let token = STANDARD.encode("admin:wrong");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));
set_authorization(&mut req, &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);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn challenge_admin_path_with_missing_credentials() {
let settings = create_test_settings();
let req = Request::new(Method::POST, "https://example.com/admin/keys/rotate");
let req = build_request(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);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}
Loading
Loading