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
46 changes: 45 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use error_stack::Report;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
use trusted_server_core::ec::EcContext;
use trusted_server_core::ec::admin::handle_admin_eids_lookup;
use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput};
use trusted_server_core::proxy::{
Expand Down Expand Up @@ -252,6 +254,8 @@ enum NamedRouteHandler {
TrustedServerDiscovery,
VerifySignature,
AdminNotSupported,
AdminEcNotSupported,
AdminEidsLookup,
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
Expand Down Expand Up @@ -279,7 +283,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];

fn named_routes() -> [NamedRoute; 12] {
fn named_routes() -> [NamedRoute; 15] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
Expand All @@ -304,6 +308,26 @@ fn named_routes() -> [NamedRoute; 12] {
primary_methods: &[Method::POST],
handler: NamedRouteHandler::AdminNotSupported,
},
// Admin EC lookup routes. Registered explicitly (like the key routes
// above) so they never fall through to the publisher fallback, and
// they match `Settings::ADMIN_ENDPOINTS` for auth coverage.
NamedRoute {
path: "/_ts/admin/ec",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::AdminEcNotSupported,
},
NamedRoute {
path: "/_ts/admin/ec/{id}",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::AdminEcNotSupported,
},
// Admin EIDs echo: pure request inspection (no KV), so the dev
// server serves the real handler.
NamedRoute {
path: "/_ts/admin/eids",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::AdminEidsLookup,
},
// The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with
// a 404, matching the Fastly and Cloudflare adapters: the production
// basic-auth handler regex `^/_ts/admin` does not match them, and letting
Expand Down Expand Up @@ -388,6 +412,26 @@ fn named_route_handler(
);
Ok(resp)
}
NamedRouteHandler::AdminEcNotSupported => {
// The EC identity graph is Fastly KV backed; the Axum
// dev server has no store to read.
let body = edgezero_core::body::Body::from(
"Admin EC lookup is not supported on the Axum dev server.\n\
Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n",
);
let mut resp = Response::new(body);
*resp.status_mut() = StatusCode::NOT_IMPLEMENTED;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
Ok(resp)
}
NamedRouteHandler::AdminEidsLookup => {
let partner_registry =
PartnerRegistry::from_config(&state.settings.ec.partners)?;
handle_admin_eids_lookup(&partner_registry, &req)
}
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
NamedRouteHandler::Auction => {
// Build the geo-aware EC context so the auction consent
Expand Down
60 changes: 60 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ fn all_explicit_routes_are_registered() {
("POST", "/verify-signature"),
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
("GET", "/_ts/admin/ec"),
("GET", "/_ts/admin/ec/{id}"),
("GET", "/_ts/admin/eids"),
("POST", "/admin/keys/rotate"),
("POST", "/admin/keys/deactivate"),
("POST", "/auction"),
Expand Down Expand Up @@ -256,6 +259,63 @@ async fn admin_route_without_credentials_returns_401() {
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_admin_ec_routes_return_501() {
// The EC identity graph is Fastly KV backed, so the Axum dev server
// answers the admin EC lookup routes locally with 501 instead of letting
// them fall through to the publisher fallback.
let sample_ec_id = format!("{}.abc123", "a".repeat(64));
for path in [
"/_ts/admin/ec".to_owned(),
format!("/_ts/admin/ec/{sample_ec_id}"),
] {
let mut svc = make_service();
let req = Request::builder()
.method("GET")
.uri(&path)
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
.body(AxumBody::empty())
.expect("should build request");
let resp = svc
.ready()
.await
.expect("should be ready")
.call(req)
.await
.expect("should respond");
assert_eq!(
resp.status().as_u16(),
501,
"{path} should report that Axum EC lookup is unsupported"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_admin_eids_route_returns_200() {
// The EIDs echo is pure request inspection (no KV), so the dev server
// serves the real handler.
let mut svc = make_service();
let req = Request::builder()
.method("GET")
.uri("/_ts/admin/eids")
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
.body(AxumBody::empty())
.expect("should build request");
let resp = svc
.ready()
.await
.expect("should be ready")
.call(req)
.await
.expect("should respond");
assert_eq!(
resp.status().as_u16(),
200,
"/_ts/admin/eids should serve the real EIDs echo handler"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() {
// Regression for the credential-leak finding: the production basic-auth regex
Expand Down
36 changes: 36 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
#[cfg(target_arch = "wasm32")]
use trusted_server_core::config_payload::settings_from_config_blob;
use trusted_server_core::ec::EcContext;
use trusted_server_core::ec::admin::handle_admin_eids_lookup;
use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput};
use trusted_server_core::platform::RuntimeServices;
Expand Down Expand Up @@ -242,6 +244,20 @@ fn admin_key_management_not_supported() -> Response {
response
}

fn admin_ec_lookup_not_supported() -> Response {
let body = edgezero_core::body::Body::from(
"Admin EC lookup is not supported on Cloudflare Workers.\n\
Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n",
);
let mut response = Response::new(body);
*response.status_mut() = StatusCode::NOT_IMPLEMENTED;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}

/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*`
/// aliases on the Cloudflare adapter.
///
Expand Down Expand Up @@ -461,6 +477,26 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
.post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async {
Ok::<Response, EdgeError>(admin_key_management_not_supported())
})
// Admin EC lookup routes. Registered explicitly (like the key
// routes above) so they never fall through to the publisher
// fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth
// coverage. The EC identity graph is Fastly KV backed, so this
// adapter has no store to read.
.get("/_ts/admin/ec", |_ctx: RequestContext| async {
Ok::<Response, EdgeError>(admin_ec_lookup_not_supported())
})
.get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async {
Ok::<Response, EdgeError>(admin_ec_lookup_not_supported())
})
// Admin EIDs echo: pure request inspection (no KV), so this
// adapter serves the real handler.
.get(
"/_ts/admin/eids",
make_handler(Arc::clone(&state), |s, _services, req| async move {
let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?;
handle_admin_eids_lookup(&partner_registry, &req)
}),
)
.post(
"/auction",
make_handler(Arc::clone(&state), |s, services, req| async move {
Expand Down
48 changes: 48 additions & 0 deletions crates/trusted-server-adapter-cloudflare/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ fn all_explicit_routes_are_registered() {
("POST", "/verify-signature"),
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
("GET", "/_ts/admin/ec"),
("GET", "/_ts/admin/ec/{id}"),
("GET", "/_ts/admin/eids"),
("POST", "/auction"),
("GET", "/first-party/proxy"),
("GET", "/first-party/click"),
Expand Down Expand Up @@ -264,6 +267,51 @@ async fn authenticated_admin_routes_return_501() {
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_admin_ec_routes_return_501() {
// The EC identity graph is Fastly KV backed, so Cloudflare answers the
// admin EC lookup routes locally with 501 instead of letting them fall
// through to the publisher fallback.
let sample_ec_id = format!("{}.abc123", "a".repeat(64));
for path in [
"/_ts/admin/ec".to_owned(),
format!("/_ts/admin/ec/{sample_ec_id}"),
] {
let req = request_builder()
.method("GET")
.uri(&path)
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
.body(edgezero_core::body::Body::empty())
.expect("should build request");
let resp = route(test_router(), req).await;

assert_eq!(
resp.status().as_u16(),
501,
"{path} should report that Cloudflare EC lookup is unsupported"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_admin_eids_route_returns_200() {
// The EIDs echo is pure request inspection (no KV), so this adapter
// serves the real handler.
let req = request_builder()
.method("GET")
.uri("/_ts/admin/eids")
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
.body(edgezero_core::body::Body::empty())
.expect("should build request");
let resp = route(test_router(), req).await;

assert_eq!(
resp.status().as_u16(),
200,
"/_ts/admin/eids should serve the real EIDs echo handler"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn admin_route_without_credentials_returns_401() {
let router = test_router();
Expand Down
75 changes: 75 additions & 0 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
//! | POST | `/verify-signature` | [`handle_verify_signature`] |
//! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] |
//! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] |
//! | GET | `/_ts/admin/ec` | [`handle_admin_ec_lookup`] |
//! | GET | `/_ts/admin/ec/{id}` | [`handle_admin_ec_lookup`] |
//! | GET | `/_ts/admin/eids` | [`handle_admin_eids_lookup`] |
//! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] |
//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] |
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
Expand Down Expand Up @@ -98,6 +101,7 @@ use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS};
use trusted_server_core::ec::EcContext;
use trusted_server_core::ec::admin::{handle_admin_ec_lookup, handle_admin_eids_lookup};
use trusted_server_core::ec::batch_sync::handle_batch_sync;
use trusted_server_core::ec::consent::ec_consent_withdrawn;
use trusted_server_core::ec::device::DeviceSignals;
Expand Down Expand Up @@ -565,6 +569,18 @@ async fn run_named_route(
}
NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req),
NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req),
NamedRouteHandler::AdminEcLookup => {
// Deliberately NOT `ec.kv_graph`: that copy is bot-gated (None for
// non-browser clients), and operators hit this auth-gated endpoint
// with curl. Build the graph directly from settings instead.
let kv = crate::maybe_identity_graph(&state.settings);
let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?;
handle_admin_ec_lookup(kv.as_ref(), &partner_registry, &req)
}
NamedRouteHandler::AdminEidsLookup => {
let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?;
handle_admin_eids_lookup(&partner_registry, &req)
}
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
NamedRouteHandler::BatchSync => {
// Dispatched by execute_named before EC state is built.
Expand Down Expand Up @@ -987,6 +1003,8 @@ enum NamedRouteHandler {
VerifySignature,
RotateKey,
DeactivateKey,
AdminEcLookup,
AdminEidsLookup,
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
Expand Down Expand Up @@ -1039,6 +1057,25 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::POST],
handler: NamedRouteHandler::DeactivateKey,
},
// Admin EC lookup: the bare route reads the EC ID from the caller's
// `ts-ec` cookie; the parameterized route takes an explicit EC ID.
NamedRoute {
path: "/_ts/admin/ec",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::AdminEcLookup,
},
NamedRoute {
path: "/_ts/admin/ec/{id}",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::AdminEcLookup,
},
// Admin EIDs echo: decodes the request's ts-eids/sharedId cookies with
// an ingestion preview. Pure request inspection — no KV access.
NamedRoute {
path: "/_ts/admin/eids",
primary_methods: &[Method::GET],
handler: NamedRouteHandler::AdminEidsLookup,
},
// The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with a
// 404 instead of executing key operations: the production basic-auth handler
// regex `^/_ts/admin` does not match them, and letting them fall through to
Expand Down Expand Up @@ -1624,6 +1661,44 @@ mod tests {
}
}

#[test]
fn admin_ec_lookup_routes_are_registered() {
// Both lookup shapes must be explicitly routed to the admin EC
// handler: the bare cookie-based route and the parameterized route.
// Leaving either unrouted would fall through to the publisher
// fallback, forwarding the caller's `Authorization` header to the
// origin.
for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] {
let route = NAMED_ROUTES
.iter()
.find(|route| route.path == path)
.unwrap_or_else(|| panic!("{path} must be a named route"));
assert!(
matches!(route.handler, NamedRouteHandler::AdminEcLookup),
"{path} must map to the admin EC lookup handler"
);
assert_eq!(
route.primary_methods,
&[Method::GET],
"{path} must have GET as its only primary method"
);
}

let eids_route = NAMED_ROUTES
.iter()
.find(|route| route.path == "/_ts/admin/eids")
.expect("should register /_ts/admin/eids as a named route");
assert!(
matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup),
"/_ts/admin/eids must map to the admin EIDs lookup handler"
);
assert_eq!(
eids_route.primary_methods,
&[Method::GET],
"/_ts/admin/eids must have GET as its only primary method"
);
}

#[test]
fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() {
// Regression for the credential-leak finding: with a production-shaped
Expand Down
Loading
Loading