Skip to content

Commit 4847192

Browse files
Address APS OpenRTB review findings
1 parent ca66dc2 commit 4847192

22 files changed

Lines changed: 1121 additions & 276 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12-
- **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.
12+
- **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 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.
1313
- **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.
1414
- **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading.
1515
- Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity.

TESTING.md

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ curl -X POST http://localhost:7676/auction \
4646

4747
**With Orchestrator Enabled** (`auction.enabled = true`):
4848
- Logs showing: `"Using auction orchestrator"`
49-
- Parallel execution of APS (mocked) and Prebid (real)
50-
- GAM mediation (mocked) selecting winning bids
49+
- Parallel execution of APS OpenRTB and Prebid Server
50+
- Optional mock-adserver mediation selecting winning bids
5151
- Final response with winning creatives
5252

5353
**With Orchestrator Disabled** (`auction.enabled = false`):
@@ -66,11 +66,13 @@ providers = ["prebid", "aps"]
6666
mediator = "adserver_mock" # If set: mediation, if omitted: highest bid wins
6767
timeout_ms = 2000
6868

69-
# Mock provider configs
69+
# APS OpenRTB provider. The built-in production endpoint is used when
70+
# endpoint is omitted; use only an account authorized for test traffic.
7071
[integrations.aps]
7172
enabled = true
72-
mock = true
73-
mock_price = 2.50
73+
account_id = "example-account"
74+
timeout_ms = 800
75+
debug = false
7476

7577
[integrations.adserver_mock]
7678
enabled = true
@@ -90,10 +92,10 @@ mediator = "adserver_mock" # Mediator configured = parallel mediation strategy
9092
```
9193

9294
**Expected Flow:**
93-
1. Prebid queries real SSPs
94-
2. APS returns mock bids ($2.50 CPM)
95-
3. AdServer Mock mediates between all bids
96-
4. Winning creative returned
95+
1. Prebid queries its configured bidders through Prebid Server
96+
2. APS sends an OpenRTB request for eligible banner impressions
97+
3. AdServer Mock mediates the provider responses
98+
4. The winning creative or typed APS renderer is returned
9799

98100
### Scenario 2: Parallel Only (No Mediation)
99101
**Config:**
@@ -157,18 +159,19 @@ INFO: Registering auction provider: adserver_mock
157159

158160
## Next Steps
159161

160-
1. **Test with real Prebid Server** - Verify Prebid bids work correctly
161-
2. **Implement real APS** - Replace mock with actual Amazon TAM API calls
162-
3. **Implement real GAM** - Add Google Ad Manager API integration
163-
4. **Add metrics** - Track bid rates, win rates, latency per provider
162+
1. **Verify Prebid Server demand** - Confirm configured bidders return expected test bids
163+
2. **Verify APS eligibility** - Confirm the test account, inventory identity, and `/e/pb/bid` endpoint are authorized
164+
3. **Exercise renderer security** - Run the APS browser integration suite for iframe and script creatives
165+
4. **Add metrics** - Track bid rates, win rates, latency, and aggregate drop reasons per provider
164166

165-
## Mock Provider Behavior
167+
## Provider Behavior
166168

167169
### APS (Amazon)
168-
- Returns bids for all slots
169-
- Default mock price: $2.50 CPM
170-
- Always returns 2 bids
171-
- Response time: ~80ms (simulated)
170+
- Sends real OpenRTB requests for eligible banner slots
171+
- Safely drops malformed, unsupported, or unrenderable bids and reports aggregate reasons
172+
- Reduces multiple APS candidates to one winner per impression
173+
- Returns typed renderer descriptors rather than exposing `adm` outside the sandbox
174+
- Automated tests intercept upstream traffic and use fictional response fixtures
172175

173176
### AdServer Mock
174177
- Acts as mediator by calling mocktioneer's mediation endpoint

crates/trusted-server-core/src/auction/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ When a request arrives at the `/auction` endpoint, it goes through the following
117117
118118
┌──────────────────────────────────────────────────────────────────────┐
119119
│ 9. Each Provider Processes Request │
120-
│ - Transform AuctionRequest → Provider OpenRTB request │
120+
│ - Transform AuctionRequest → Provider OpenRTB request
121121
│ - Send HTTP request to provider endpoint │
122122
│ - Parse provider response │
123123
│ - Transform → AuctionResponse with Bid[] │
@@ -201,7 +201,7 @@ For example, APS provider:
201201
// - banner slots become secure impressions with matching formats/floors
202202
// - existing consent, identity, device, and geo privacy gates apply
203203

204-
// HTTP POST to https://web.ads.aps.amazon-adsystem.com/e/pb/bid
204+
// HTTP POST to https://aps.example.com/e/pb/bid
205205
// Parse decoded-price response → AuctionResponse with a typed renderer
206206
```
207207

crates/trusted-server-core/src/auction/formats.rs

Lines changed: 176 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,36 @@ fn publisher_page_url(req: &Request<EdgeBody>, publisher_domain: &str) -> String
9898
.and_then(|value| value.to_str().ok())
9999
.filter(|value| value.len() <= MAX_PUBLISHER_PAGE_URL_BYTES)
100100
else {
101+
log::debug!(
102+
"Auction page URL: using publisher origin because Referer is absent or invalid"
103+
);
101104
return fallback;
102105
};
103-
let Ok(parsed) = Url::parse(candidate) else {
106+
let Ok(mut parsed) = Url::parse(candidate) else {
107+
log::debug!("Auction page URL: using publisher origin because Referer is not a URL");
104108
return fallback;
105109
};
110+
let publisher_domain = publisher_domain.trim_end_matches('.').to_ascii_lowercase();
111+
let host_matches = !publisher_domain.is_empty()
112+
&& parsed.host_str().is_some_and(|host| {
113+
let host = host.trim_end_matches('.').to_ascii_lowercase();
114+
host == publisher_domain
115+
|| host
116+
.strip_suffix(&publisher_domain)
117+
.is_some_and(|prefix| prefix.ends_with('.'))
118+
});
106119
if !matches!(parsed.scheme(), "http" | "https")
107-
|| !parsed
108-
.host_str()
109-
.is_some_and(|host| host.eq_ignore_ascii_case(publisher_domain))
120+
|| !host_matches
110121
|| !parsed.username().is_empty()
111122
|| parsed.password().is_some()
112123
{
124+
log::debug!(
125+
"Auction page URL: using publisher origin because Referer is not publisher-owned HTTP(S)"
126+
);
113127
return fallback;
114128
}
129+
parsed.set_query(None);
130+
parsed.set_fragment(None);
115131
parsed.to_string()
116132
}
117133

@@ -255,7 +271,7 @@ pub fn convert_tsjs_to_auction_request(
255271
/// # Errors
256272
///
257273
/// Returns an error if:
258-
/// - A winning bid is missing a price or render source
274+
/// - A winning bid is missing a price
259275
/// - The response serialization fails
260276
pub fn convert_to_openrtb_response(
261277
result: &OrchestrationResult,
@@ -285,7 +301,19 @@ pub fn convert_to_openrtb_response(
285301

286302
// Ordinary markup remains on the mandatory sanitize/rewrite path. A
287303
// typed renderer is serialized separately and never enters the HTML sanitizer.
288-
let (adm, ext) = if let Some(ref raw_creative) = bid.creative {
304+
let (adm, ext) = if let Some(raw_creative) = bid
305+
.creative
306+
.as_deref()
307+
.filter(|creative| !creative.trim().is_empty())
308+
{
309+
if bid.renderer.is_some() {
310+
log::warn!(
311+
"Auction {}: winning bid for slot '{}' from '{}' has both creative markup and a renderer; using creative markup",
312+
auction_request.id,
313+
slot_id,
314+
bid.bidder
315+
);
316+
}
289317
let sanitized = creative::sanitize_creative_html(raw_creative);
290318
let rewritten = creative::rewrite_creative_html(settings, &sanitized);
291319

@@ -300,20 +328,27 @@ pub fn convert_to_openrtb_response(
300328

301329
(Some(rewritten), None)
302330
} else if let Some(renderer) = bid.renderer.as_ref() {
303-
(
304-
None,
305-
BidExt {
306-
trusted_server: BidTrustedServerExt { renderer },
307-
}
308-
.to_ext(),
309-
)
331+
let Some(ext) = (BidExt {
332+
trusted_server: BidTrustedServerExt { renderer },
333+
})
334+
.to_ext() else {
335+
log::warn!(
336+
"Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized",
337+
auction_request.id,
338+
slot_id,
339+
bid.bidder
340+
);
341+
continue;
342+
};
343+
(None, Some(ext))
310344
} else {
311-
return Err(Report::new(TrustedServerError::Auction {
312-
message: format!(
313-
"Winning bid for slot '{}' from '{}' has no render source",
314-
slot_id, bid.bidder
315-
),
316-
}));
345+
log::warn!(
346+
"Auction {}: skipping winning bid for slot '{}' from '{}' because it has no render source",
347+
auction_request.id,
348+
slot_id,
349+
bid.bidder
350+
);
351+
continue;
317352
};
318353

319354
let openrtb_bid = OpenRtbBid {
@@ -657,30 +692,48 @@ mod tests {
657692
}
658693

659694
#[test]
660-
fn publisher_page_url_accepts_only_same_publisher_http_urls() {
661-
let request = Request::builder()
662-
.uri("https://publisher.example.com/auction")
663-
.header(
664-
header::REFERER,
665-
"https://publisher.example.com/article?id=1",
666-
)
667-
.body(EdgeBody::empty())
668-
.expect("should build request");
669-
assert_eq!(
670-
publisher_page_url(&request, "publisher.example.com"),
671-
"https://publisher.example.com/article?id=1"
672-
);
673-
assert_eq!(
674-
publisher_page_url(&request, "Publisher.Example.Com"),
675-
"https://publisher.example.com/article?id=1",
676-
"DNS host comparison should be case-insensitive"
677-
);
695+
fn publisher_page_url_accepts_publisher_hosts_and_strips_private_components() {
696+
for (candidate, expected) in [
697+
(
698+
"https://publisher.example.com/article?id=1#comments",
699+
"https://publisher.example.com/article",
700+
),
701+
(
702+
"http://news.publisher.example.com/nested/story?token=secret",
703+
"http://news.publisher.example.com/nested/story",
704+
),
705+
(
706+
"https://www.publisher.example.com/",
707+
"https://www.publisher.example.com/",
708+
),
709+
] {
710+
let request = Request::builder()
711+
.uri("https://publisher.example.com/auction")
712+
.header(header::REFERER, candidate)
713+
.body(EdgeBody::empty())
714+
.expect("should build request");
715+
assert_eq!(
716+
publisher_page_url(&request, "Publisher.Example.Com"),
717+
expected,
718+
"should sanitize publisher-owned Referer"
719+
);
720+
}
721+
}
678722

723+
#[test]
724+
fn publisher_page_url_rejects_untrusted_or_oversized_referers() {
725+
let oversized = format!(
726+
"https://publisher.example.com/{}",
727+
"x".repeat(MAX_PUBLISHER_PAGE_URL_BYTES)
728+
);
679729
for candidate in [
680730
"https://other.example/article",
731+
"https://publisher.example.com.evil.example/article",
732+
"https://evilpublisher.example.com/article",
681733
"javascript:alert(1)",
682734
"https://user:password@publisher.example.com/article",
683735
"not a url",
736+
&oversized,
684737
] {
685738
let request = Request::builder()
686739
.uri("https://publisher.example.com/auction")
@@ -1064,19 +1117,98 @@ mod tests {
10641117
}
10651118

10661119
#[test]
1067-
fn convert_to_openrtb_response_rejects_winner_without_render_source() {
1120+
fn convert_to_openrtb_response_skips_invalid_winners_without_dropping_valid_slots() {
10681121
let settings = make_settings();
10691122
let auction_request = make_auction_request();
1070-
let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
1071-
bid.creative = None;
1123+
let mut missing = make_bid("missing", "invalid", Some(3.0));
1124+
missing.creative = None;
1125+
let mut whitespace = make_bid("whitespace", "invalid", Some(2.9));
1126+
whitespace.creative = Some(" \n\t ".to_string());
1127+
let ordinary = make_bid("ordinary", "appnexus", Some(2.75));
1128+
let mut renderer = make_bid("renderer", "aps", Some(2.5));
1129+
renderer.creative = Some(" ".to_string());
1130+
renderer.bid_id = Some("upstream-renderer-bid".to_string());
1131+
renderer.creative_id = None;
1132+
renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
1133+
version: 1,
1134+
account_id: "example-account".to_string(),
1135+
bid_id: "upstream-renderer-bid".to_string(),
1136+
creative_id: None,
1137+
tag_type: ApsTagType::Iframe,
1138+
creative_url: "https://creative.example/render".to_string(),
1139+
aax_response: "fictional-base64".to_string(),
1140+
width: 300,
1141+
height: 250,
1142+
}));
1143+
let result = OrchestrationResult {
1144+
provider_responses: vec![],
1145+
mediator_response: None,
1146+
winning_bids: HashMap::from([
1147+
(missing.slot_id.clone(), missing),
1148+
(whitespace.slot_id.clone(), whitespace),
1149+
(ordinary.slot_id.clone(), ordinary),
1150+
(renderer.slot_id.clone(), renderer),
1151+
]),
1152+
total_time_ms: 50,
1153+
metadata: HashMap::new(),
1154+
};
1155+
1156+
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
1157+
.expect("should omit invalid winners and preserve valid slots");
1158+
let json = response_json(response);
1159+
let bids: Vec<&JsonValue> = json["seatbid"]
1160+
.as_array()
1161+
.expect("should include valid seatbids")
1162+
.iter()
1163+
.map(|seatbid| &seatbid["bid"][0])
1164+
.collect();
1165+
1166+
assert_eq!(bids.len(), 2, "should omit only invalid winners");
1167+
let ordinary = bids
1168+
.iter()
1169+
.find(|bid| bid["impid"] == "ordinary")
1170+
.expect("should preserve ordinary winner");
1171+
assert_eq!(ordinary["adm"], "<div>Ad</div>");
1172+
let renderer = bids
1173+
.iter()
1174+
.find(|bid| bid["impid"] == "renderer")
1175+
.expect("should preserve renderer winner");
1176+
assert!(renderer.get("adm").is_none(), "should omit renderer adm");
1177+
assert!(
1178+
renderer.get("crid").is_none(),
1179+
"should omit absent upstream crid"
1180+
);
1181+
assert_eq!(renderer["id"], "upstream-renderer-bid");
1182+
assert!(renderer.get("ext").is_some(), "should include renderer ext");
1183+
}
1184+
1185+
#[test]
1186+
fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() {
1187+
let settings = make_settings();
1188+
let auction_request = make_auction_request();
1189+
let mut bid = make_bid("div-gpt-top", "aps", Some(2.75));
1190+
bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 {
1191+
version: 1,
1192+
account_id: "example-account".to_string(),
1193+
bid_id: "fictional-bid".to_string(),
1194+
creative_id: None,
1195+
tag_type: ApsTagType::Iframe,
1196+
creative_url: "https://creative.example/render".to_string(),
1197+
aax_response: "fictional-base64".to_string(),
1198+
width: 300,
1199+
height: 250,
1200+
}));
10721201
let result = make_result(bid);
10731202

1074-
let error = convert_to_openrtb_response(&result, &settings, &auction_request, false)
1075-
.expect_err("should reject bid without a render source");
1203+
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
1204+
.expect("should prefer ordinary creative markup");
1205+
let json = response_json(response);
1206+
let bid = &json["seatbid"][0]["bid"][0];
10761207

1208+
assert_eq!(bid["adm"], "<div>Ad</div>");
10771209
assert!(
1078-
format!("{error:?}").contains("has no render source"),
1079-
"should explain the missing render source"
1210+
bid.get("ext").is_none(),
1211+
"should omit renderer extension when creative markup wins precedence"
10801212
);
10811213
}
10821214

crates/trusted-server-core/src/auction/types.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,11 @@ pub enum BidRenderer {
215215
}
216216

217217
impl BidRenderer {
218-
/// Return the APS renderer descriptor.
218+
/// Return the APS renderer descriptor when this is an APS renderer.
219219
#[must_use]
220-
pub fn aps(&self) -> &ApsRendererV1 {
220+
pub fn as_aps(&self) -> Option<&ApsRendererV1> {
221221
match self {
222-
Self::Aps(renderer) => renderer,
222+
Self::Aps(renderer) => Some(renderer),
223223
}
224224
}
225225
}

0 commit comments

Comments
 (0)