diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index b785734968..5bdc36e496 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -72,6 +72,7 @@ async fn aps_profile_serves_renderer_through_adapter_fallback() { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 668b025ccb..c569050e16 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -847,6 +847,7 @@ mod tests { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), @@ -913,6 +914,7 @@ mod tests { ( id.parse().expect("should parse provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{id}.example/openrtb"), @@ -957,6 +959,7 @@ mod tests { ( id.parse().expect("should parse provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{id}.example/openrtb"), diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 69675e2669..e7f19055dd 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1829,6 +1829,7 @@ mod tests { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 9edce69fa3..edf956da2d 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1038,6 +1038,7 @@ mod tests { ( id.parse().expect("should parse provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{id}.example/openrtb"), @@ -1062,6 +1063,7 @@ mod tests { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 0c49c1e534..576bde7719 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -625,6 +625,7 @@ mod tests { providers: BTreeMap::from([( ProviderId::from_str("pbs-primary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://pbs.example.test/openrtb".to_string(), diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 4323039284..7855e9d46e 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -215,6 +215,7 @@ mod plan_sharing_tests { settings.auction.providers = std::collections::BTreeMap::from([( "aps-main".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index 12c885d88e..280c87501c 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -960,6 +960,7 @@ mod routing_metadata_tests { providers: BTreeMap::from([( provider_id.clone(), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: if profile == "aps" { diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index f47efa475e..d64a57286d 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -42,6 +42,7 @@ fn config_with_endpoint(profile: &str, profile_config: Value, endpoint: &str) -> providers: BTreeMap::from([( provider_id.clone(), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: endpoint.to_string(), diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index f665441723..e3e4656cf3 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2835,6 +2835,7 @@ mod tests { ( ProviderId::from_str(id).expect("should parse fictional provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: "https://example.test/openrtb".to_string(), @@ -2867,6 +2868,7 @@ mod tests { ( ProviderId::from_str(id).expect("should parse fictional provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: format!("https://{id}.example.test/openrtb"), @@ -2903,6 +2905,7 @@ mod tests { ( ProviderId::from_str(id).expect("should parse fictional provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index df36fc09b7..ecc89734c9 100644 --- a/crates/trusted-server-core/src/auction/plan.rs +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -135,6 +135,10 @@ pub struct ProviderConfig { pub profile: String, /// Fixed provider endpoint. pub endpoint: String, + /// Permit unencrypted HTTP for this provider only. Defaults to false. + /// Use only for explicitly approved demo/test endpoints. + #[serde(default, skip_serializing_if = "is_false")] + pub allow_insecure_http: bool, /// Optional profile-default timeout override. #[serde(default)] pub timeout_ms: Option, @@ -314,7 +318,12 @@ impl AuctionPlan { "provider `{id}` profile_config must be an object" ))); } - let endpoint = canonicalize_endpoint(&id, registration.id, &raw.endpoint)?; + let endpoint = canonicalize_endpoint( + &id, + registration.id, + &raw.endpoint, + raw.allow_insecure_http, + )?; let timeout_ms = raw .timeout_ms .unwrap_or(match registration.default_timeout { @@ -566,13 +575,14 @@ fn canonicalize_endpoint( provider_id: &ProviderId, profile_id: &str, value: &str, + allow_insecure_http: bool, ) -> Result> { let mut endpoint = Url::parse(value).map_err(|error| { configuration_error(format!( "provider `{provider_id}` endpoint must be an absolute HTTPS URL: {error}" )) })?; - if endpoint.scheme() != "https" + if !(endpoint.scheme() == "https" || (allow_insecure_http && endpoint.scheme() == "http")) || endpoint.host_str().is_none() || !endpoint.username().is_empty() || endpoint.password().is_some() @@ -598,6 +608,10 @@ fn canonicalize_endpoint( Ok(CanonicalProviderEndpoint(endpoint)) } +fn is_false(value: &bool) -> bool { + !*value +} + fn normalize_prebid_server_endpoint(endpoint: &mut Url) { match endpoint.path() { "" | "/" => endpoint.set_path("/openrtb2/auction"), @@ -644,6 +658,7 @@ mod tests { fn provider(profile: &str) -> ProviderConfig { ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: "https://bid.example/openrtb2/auction".to_string(), @@ -1123,6 +1138,82 @@ mod tests { assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); } + #[test] + fn insecure_http_requires_an_explicit_per_provider_opt_in() { + let mut demo = provider("prebid-server"); + demo.endpoint = "http://pbs.example.com:8000".to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("demo"), demo.clone())]))).is_err(), + "should reject HTTP without the provider's explicit opt-in" + ); + demo.allow_insecure_http = true; + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("demo"), demo.clone())]))) + .expect("should compile an explicitly opted-in HTTP provider"); + assert_eq!( + plan.providers()[0].endpoint.as_str(), + "http://pbs.example.com:8000/openrtb2/auction", + "should normalize the opted-in Prebid Server endpoint" + ); + let mut other = provider("standard"); + other.endpoint = "http://other.example.com/bid".to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([ + (id("demo"), demo), + (id("other"), other) + ]))) + .is_err(), + "should not inherit another provider's HTTP opt-in" + ); + } + + #[test] + fn insecure_http_opt_in_does_not_allow_credentials_fragments_or_other_schemes() { + for endpoint in [ + "http://user:pass@pbs.example.com/bid", + "http://pbs.example.com/bid#fragment", + "ftp://pbs.example.com/bid", + "/relative", + ] { + let mut raw = provider("prebid-server"); + raw.allow_insecure_http = true; + raw.endpoint = endpoint.to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("demo"), raw)]))).is_err(), + "should reject {endpoint}" + ); + } + } + + #[test] + fn insecure_http_default_preserves_existing_provider_wire_shape() { + let legacy = serde_json::to_value(provider("prebid-server")) + .expect("should serialize the default provider"); + assert!( + legacy.get("allow_insecure_http").is_none(), + "should omit the default HTTP opt-in field" + ); + assert!( + !serde_json::from_value::(legacy) + .expect("should deserialize the default provider") + .allow_insecure_http, + "should keep HTTP disabled when the field is absent" + ); + let mut opted_in = provider("prebid-server"); + opted_in.allow_insecure_http = true; + let encoded = + serde_json::to_value(opted_in).expect("should serialize the opted-in provider"); + assert_eq!( + encoded["allow_insecure_http"], true, + "should publish the explicit HTTP opt-in" + ); + assert!( + serde_json::from_value::(encoded) + .expect("should deserialize the opted-in provider") + .allow_insecure_http, + "should retain the explicit HTTP opt-in across serialization" + ); + } + #[test] fn compiler_normalizes_only_prebid_server_origin_and_canonical_paths() { for (configured, expected) in [ diff --git a/crates/trusted-server-core/src/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs index 4134ae6a7f..1ca8297d20 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -579,6 +579,7 @@ mod tests { fn provider(profile: &str, routing: RoutingMode) -> ProviderConfig { ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: format!("https://{profile}.example.test/openrtb"), diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 7f506b89bf..36b85dbd95 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -207,6 +207,7 @@ impl AuctionConfig { ( id, ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{name}.example/openrtb2/auction"), diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 141a06867e..732749686c 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -911,6 +911,7 @@ formats = [{ width = 300, height = 250 }] settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example.com/e/pb/bid".to_string(), diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 8a20c72965..75c893d4a9 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -2383,6 +2383,7 @@ mod tests { providers: BTreeMap::from([( ProviderId::from_str("pbs-primary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://pbs.example.test/openrtb".to_string(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 62f8c8f068..08469c4d74 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1939,6 +1939,7 @@ pub fn register( providers: BTreeMap::from([( "aps".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: default_endpoint(), @@ -3077,6 +3078,7 @@ mod tests { providers: BTreeMap::from([( "aps".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: default_endpoint(), diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c13260704f..817f7ec859 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -5622,6 +5622,7 @@ external_bundle_sri = "sha384-AAAA" ( ProviderId::from_str("pbs-primary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://primary.example.test/openrtb".to_string(), @@ -5634,6 +5635,7 @@ external_bundle_sri = "sha384-AAAA" ( ProviderId::from_str("pbs-secondary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://secondary.example.test/openrtb".to_string(), @@ -9098,6 +9100,7 @@ set = { networkId = 42 } providers: BTreeMap::from([( provider_id.clone(), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://pbs.example/openrtb2/auction".to_string(), diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 094212dc4d..298c3499f5 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -773,15 +773,16 @@ registered integration selected by `[auction].mediator`. Common provider fields and defaults: -| Field | Default | Meaning | -| ---------------- | --------------- | -------------------------------------------------------------- | -| `protocol` | Required | `openrtb-2.6` | -| `profile` | `standard` | Typed OpenRTB behavior | -| `endpoint` | Required | Fixed absolute HTTPS endpoint | -| `timeout_ms` | Profile default | PBS 1000 ms, APS 800 ms, standard inherits auction timeout | -| `routing` | `explicit` | `explicit`, or `all_eligible` for non-PBS profiles | -| `profile_config` | `{}` | Profile-owned typed settings | -| `notifications` | No suppression | Common `nurl`/`burl` suppression by all bids or returned seats | +| Field | Default | Meaning | +| --------------------- | --------------- | ---------------------------------------------------------------------------------- | +| `protocol` | Required | `openrtb-2.6` | +| `profile` | `standard` | Typed OpenRTB behavior | +| `endpoint` | Required | Fixed absolute HTTPS endpoint | +| `allow_insecure_http` | `false` | Explicit per-provider HTTP opt-in for approved demos/tests; traffic is unencrypted | +| `timeout_ms` | Profile default | PBS 1000 ms, APS 800 ms, standard inherits auction timeout | +| `routing` | `explicit` | `explicit`, or `all_eligible` for non-PBS profiles | +| `profile_config` | `{}` | Profile-owned typed settings | +| `notifications` | No suppression | Common `nurl`/`burl` suppression by all bids or returned seats | APS normally uses `all_eligible`, which sends every compatible banner slot but never another provider's bidder parameters. `explicit` providers receive only diff --git a/docs/superpowers/specs/2026-11-10-http-auction-provider-opt-in-design.md b/docs/superpowers/specs/2026-11-10-http-auction-provider-opt-in-design.md new file mode 100644 index 0000000000..9f27fd9d5c --- /dev/null +++ b/docs/superpowers/specs/2026-11-10-http-auction-provider-opt-in-design.md @@ -0,0 +1,72 @@ +# Provider-specific HTTP auction opt-in + +## Intent + +Permit an explicitly configured auction provider to use unencrypted HTTP for an approved demo or test endpoint. Keep HTTPS as the default. Do not turn one provider's exception into permission for another provider. + +This documents the existing reconstructed feature at `2c79ea59`. Its formal design reviews occur before publication readiness, not before the earlier implementation. It does not authorize deployment or production use of unencrypted auction traffic. + +## Configuration contract + +`ProviderConfig.allow_insecure_http` is a boolean, defaults to `false` when absent and is omitted during serialization while false. An explicit true value survives serialization. Existing provider documents therefore keep their default wire shape. + +For one provider: + +| Parsed endpoint | Flag false or absent | Flag true | +| ------------------------------------------- | ------------------------------------- | ------------------------------------- | +| Absolute HTTPS with host | Accept subject to existing validation | Accept subject to existing validation | +| Absolute HTTP with host | Reject | Accept subject to existing validation | +| Username/password, fragment or missing host | Reject | Reject | +| Relative URL or another scheme | Reject | Reject | + +The flag is an operator-selected permission, not proof that an endpoint is approved, isolated or safe. HTTP exposes request and response data on the network. It adds no DNS/private-network allowlist and cannot make an insecure connection confidential. + +Example, with fictional inputs: + +```toml +[auction.providers.demo] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "http://bids.example.com:8000" +allow_insecure_http = true +``` + +## Boundaries + +Auction-plan compilation owns this validation. Read the flag from the same provider whose endpoint is being compiled. Do not use a global toggle, profile-wide mutable state, another provider's flag or an environment fallback. + +Keep existing profile validation and endpoint normalization. In particular, Prebid Server origin/canonical-path normalization still chooses `/openrtb2/auction`; other existing profile restrictions remain authoritative. HTTPS is not downgraded when the flag is true. + +Transport selection, redirect policy, timeout/cutoff handling, consent, bidder eligibility, request signing, caching and response limits remain unchanged. Accepting a configured URL does not promise that every runtime can reach it or override a runtime's network restrictions. + +Existing runtime/test provider literals explicitly select false unless that test is about the opt-in. No credentials or real deployment endpoints belong in examples or fixtures. + +## Required proof + +- HTTP fails without the flag and succeeds with that provider's explicit true value. +- A second HTTP provider without the flag still fails, even beside an opted-in provider. +- True does not allow credentials, fragments, relative URLs or another scheme. +- Default serialization omits the flag, absent deserialization returns false and true round-trips. +- Existing HTTPS/profile normalization cases continue passing. +- All affected adapter consumers compile and pass their target-matched checks in the final stack. A native-only plan test is not hosted/network qualification. + +The focused existing command is `cargo test --locked -p trusted-server-core auction::plan::tests`. Application and adapter checks follow the repository CI gate list. No cloud request or deployment is needed to establish the configuration contract. + +## Non-goals + +No global HTTP policy, automatic TLS fallback, alternate transport, credentials-in-URL support, new provider profile, network allowlist, production security approval or deployment workflow. + +## Review log + +The implementation existed before these independent read-only reviews. They are not retroactive preimplementation approval or deployment authorization. + +| Date | Round | Frozen target | Result | +| ---------- | -------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| 2026-11-10 | 1 | Contract SHA256 `e4ad3f9a8743fd332bd12b239cab94a6e6aa538428b6404fc0d14be5635c186e`; implementation `dd2ca144..2c79ea59` | No blocking or nonblocking findings. Provider-local permission, URL validation, serialization and consumer changes inspected. | +| 2026-11-10 | 2, independent | Same frozen contract and complete 17-file implementation delta | No findings or requested changes. No tests or builds executed by the reviewer. | + +The formatted contract retained the same meaning. This extraction replays the HTTP unit without relay ancestry onto current main; the review log records its original reviewed inputs, not a whole-tree review of later changes. Current target-matched checks remain required before readiness. + +## Related + +- [Auction orchestration guide](../../guide/auction-orchestration.md)