Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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