Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d5be3d9
Add admin endpoint to look up EC entries by id
prk-Jr Jul 17, 2026
12592bb
Do not bot-gate the admin EC lookup KV graph
prk-Jr Jul 17, 2026
9869ac7
Add admin endpoint to echo request EID cookies
prk-Jr Jul 17, 2026
8dc31cc
Add ISO 8601 companions to admin EC lookup timestamps
prk-Jr Jul 18, 2026
3c80b49
Merge branch 'main' into feat/admin-ec-lookup-endpoint
aram356 Jul 20, 2026
aa34a3c
Merge branch 'main' into feat/admin-ec-lookup-endpoint
aram356 Jul 23, 2026
866bcd4
Merge branch 'main' into feat/admin-ec-lookup-endpoint
prk-Jr Aug 17, 2026
0e1a405
Document admin diagnostics review fixes
prk-Jr Aug 18, 2026
ec8ca8c
Clarify admin authentication probes
prk-Jr Aug 18, 2026
2ec38e7
Plan admin diagnostics review fixes
prk-Jr Aug 18, 2026
e637524
Fail closed for concrete admin routes
prk-Jr Aug 18, 2026
539beba
Deny admin diagnostics in publisher fallback
prk-Jr Aug 18, 2026
17f0ac4
Keep admin diagnostics out of publisher fallback
prk-Jr Aug 18, 2026
4657cbf
Keep admin EID diagnostics read only
prk-Jr Aug 18, 2026
63e39b8
Preserve raw admin EC diagnostic records
prk-Jr Aug 18, 2026
178e2cd
Document admin EC and EID diagnostics
prk-Jr Aug 18, 2026
c70de71
Fix admin diagnostic test lint
prk-Jr Aug 18, 2026
69e1b8a
Reserve the full admin fallback namespace
prk-Jr Aug 18, 2026
e79cccb
Merge branch 'main' into feat/admin-ec-lookup-endpoint
aram356 Aug 18, 2026
1a2d16c
Document comprehensive PR 928 review fixes
prk-Jr Aug 19, 2026
cf25efb
Plan comprehensive PR 928 review fixes
prk-Jr Aug 19, 2026
f3776a9
Keep Fastly admin EC lookups read only
prk-Jr Aug 19, 2026
de61c43
Validate mixed-case admin EC auth coverage
prk-Jr Aug 19, 2026
8649b43
Explain dropped admin EID preview sources
prk-Jr Aug 19, 2026
1198eb9
Share core request cookie extraction
prk-Jr Aug 19, 2026
a3a0479
Harden admin diagnostic responses
prk-Jr Aug 19, 2026
be434c2
Clarify admin diagnostics contracts
prk-Jr Aug 19, 2026
436e25f
Tighten admin diagnostic review coverage
prk-Jr Aug 19, 2026
caf4422
Reserve the full admin namespace at the fallback boundary
prk-Jr Aug 19, 2026
8684e1b
Require prefix-level admin EC auth coverage
prk-Jr Aug 19, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace.
- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape.
- **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters.
- **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries.
Expand All @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on both the raw and percent-decoded path, and applies to every adapter.
- Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs

### Fixed
Expand Down
42 changes: 41 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,10 @@ 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::{
admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, 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 @@ -178,6 +182,10 @@ async fn dispatch_fallback(
services: &RuntimeServices,
mut req: Request,
) -> Result<Response, Report<TrustedServerError>> {
if let Some(response) = deny_admin_diagnostic_fallback(&req) {
return Ok(response);
}

trusted_server_core::integrations::gpt_diagnostics::prepare_request(&state.settings, &mut req)?;
let path = req.uri().path().to_string();
let method = req.method().clone();
Expand Down Expand Up @@ -259,6 +267,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 @@ -286,7 +296,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];

fn named_routes() -> [NamedRoute; 13] {
fn named_routes() -> [NamedRoute; 16] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
Expand All @@ -311,6 +321,26 @@ fn named_routes() -> [NamedRoute; 13] {
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 @@ -407,6 +437,16 @@ 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.
Ok(admin_ec_lookup_not_supported())
}
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
186 changes: 186 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 @@ -267,6 +270,189 @@ 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"
);
assert_eq!(
resp.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/json")
);
assert_eq!(
resp.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("no-store")
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn admin_ec_route_without_credentials_returns_401() {
let mut svc = make_service();
let req = Request::builder()
.method("GET")
.uri("/_ts/admin/ec")
.body(AxumBody::empty())
.expect("should build unauthenticated admin EC request");
let resp = svc
.ready()
.await
.expect("should be ready")
.call(req)
.await
.expect("should respond");

assert_eq!(resp.status().as_u16(), 401);
assert!(
resp.headers().contains_key("www-authenticate"),
"admin EC 401 should include the Basic authentication challenge"
);
}

#[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 authenticated_admin_diagnostic_fallback_is_denied_locally() {
let ec_id = format!("{}.abc123", "a".repeat(64));
let valid_paths = [
"/_ts/admin/ec".to_owned(),
format!("/_ts/admin/ec/{ec_id}"),
"/_ts/admin/eids".to_owned(),
];

for path in valid_paths {
for method in ["POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] {
let request = Request::builder()
.method(method)
.uri(&path)
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
.body(AxumBody::from("sensitive-admin-body"))
.expect("should build authenticated admin request");
let response = make_service()
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should respond");

assert_eq!(response.status().as_u16(), 405);
assert_eq!(
response
.headers()
.get("allow")
.and_then(|v| v.to_str().ok()),
Some("GET")
);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("no-store")
);
}
}

for path in [
"/_ts/admin/ec/".to_owned(),
format!("/_ts/admin/ec/{ec_id}/extra"),
"/_ts/admin/eids/".to_owned(),
"/_ts/admin/eids/extra".to_owned(),
"/_ts/admin/eids.json".to_owned(),
"/_ts/admin/ec;foo".to_owned(),
format!("/_ts/admin/ec%2F{ec_id}"),
// Percent-encoded separators match the `^/_ts/admin` basic-auth
// handler but not a literal-slash namespace check, so they must be
// reserved before publisher fallback forwards credentials upstream.
"/_ts/admin%2Fec".to_owned(),
"/_ts/admin%2fec".to_owned(),
// Retired non-`/_ts` alias namespace: only the two exact paths are
// routed to a local deny, so descendants and encoded separators must
// be reserved at the shared fallback boundary.
"/admin/keys".to_owned(),
"/admin/keys/rotate/extra".to_owned(),
"/admin/keys%2Frotate".to_owned(),
"/admin%2fkeys/rotate".to_owned(),
] {
for method in ["GET", "POST"] {
let request = Request::builder()
.method(method)
.uri(&path)
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
.body(AxumBody::from("sensitive-admin-body"))
.expect("should build malformed admin request");
let response = make_service()
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should respond");

assert_eq!(response.status().as_u16(), 404);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("no-store")
);
}
}
}

#[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
32 changes: 32 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,11 @@ 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::{
admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported,
deny_admin_diagnostic_fallback, 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 @@ -249,6 +254,10 @@ fn admin_key_management_not_supported() -> Response {
response
}

fn admin_ec_lookup_not_supported() -> Response {
core_admin_ec_lookup_not_supported()
}

/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*`
/// aliases on the Cloudflare adapter.
///
Expand Down Expand Up @@ -368,6 +377,9 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
) -> Result<Response, EdgeError> {
let services = build_per_request_services(&ctx);
let mut req = ctx.into_request();
if let Some(response) = deny_admin_diagnostic_fallback(&req) {
return Ok(response);
}
if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request(
&state.settings,
&mut req,
Expand Down Expand Up @@ -474,6 +486,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
Loading
Loading