Skip to content

Commit b3f5b11

Browse files
Add APS inventory identity overrides
1 parent eb8f37c commit b3f5b11

8 files changed

Lines changed: 268 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **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.
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.
15+
- Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity.
1516

1617
### Security
1718

crates/trusted-server-core/src/integrations/aps.rs

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ addEventListener('message',receive);
114114

115115
/// Configuration for the APS `OpenRTB` integration.
116116
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
117+
#[validate(schema(function = "validate_inventory_identity_override"))]
117118
pub struct ApsConfig {
118119
/// Whether APS integration is enabled.
119120
#[serde(default = "default_enabled")]
@@ -131,6 +132,14 @@ pub struct ApsConfig {
131132
/// Whether APS script creatives are eligible before winner selection.
132133
#[serde(default)]
133134
pub allow_script_creatives: bool,
135+
/// APS-authorized inventory domain used instead of the deployment hostname.
136+
#[serde(default, skip_serializing_if = "Option::is_none")]
137+
#[validate(custom(function = "validate_inventory_domain"))]
138+
pub inventory_domain: Option<String>,
139+
/// Canonical HTTPS origin used for APS `site.page` while preserving path and query.
140+
#[serde(default, skip_serializing_if = "Option::is_none")]
141+
#[validate(custom(function = "validate_inventory_page_origin"))]
142+
pub inventory_page_origin: Option<String>,
134143
}
135144

136145
fn deserialize_account_id<'de, D>(deserializer: D) -> Result<String, D::Error>
@@ -197,6 +206,78 @@ fn validate_aps_endpoint(value: &str) -> Result<(), ValidationError> {
197206
Ok(())
198207
}
199208

209+
fn validate_inventory_domain(value: &str) -> Result<(), ValidationError> {
210+
if value.trim() != value
211+
|| value.is_empty()
212+
|| value.len() > 253
213+
|| value.starts_with('.')
214+
|| value.ends_with('.')
215+
|| value.contains(['/', ':'])
216+
{
217+
return Err(ValidationError::new("invalid_aps_inventory_domain"));
218+
}
219+
for label in value.split('.') {
220+
let bytes = label.as_bytes();
221+
if label.is_empty()
222+
|| label.len() > 63
223+
|| bytes.first() == Some(&b'-')
224+
|| bytes.last() == Some(&b'-')
225+
|| !bytes
226+
.iter()
227+
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
228+
{
229+
return Err(ValidationError::new("invalid_aps_inventory_domain"));
230+
}
231+
}
232+
Ok(())
233+
}
234+
235+
fn validate_inventory_page_origin(value: &str) -> Result<(), ValidationError> {
236+
let parsed =
237+
Url::parse(value).map_err(|_| ValidationError::new("invalid_aps_inventory_page_origin"))?;
238+
if parsed.scheme() != "https"
239+
|| parsed.host_str().is_none()
240+
|| !parsed.username().is_empty()
241+
|| parsed.password().is_some()
242+
|| parsed.port().is_some()
243+
|| parsed.path() != "/"
244+
|| parsed.query().is_some()
245+
|| parsed.fragment().is_some()
246+
{
247+
return Err(ValidationError::new("invalid_aps_inventory_page_origin"));
248+
}
249+
Ok(())
250+
}
251+
252+
fn validate_inventory_identity_override(config: &ApsConfig) -> Result<(), ValidationError> {
253+
let (Some(domain), Some(origin)) = (
254+
config.inventory_domain.as_deref(),
255+
config.inventory_page_origin.as_deref(),
256+
) else {
257+
if config.inventory_domain.is_none() && config.inventory_page_origin.is_none() {
258+
return Ok(());
259+
}
260+
return Err(ValidationError::new(
261+
"incomplete_aps_inventory_identity_override",
262+
));
263+
};
264+
let parsed = Url::parse(origin)
265+
.map_err(|_| ValidationError::new("invalid_aps_inventory_page_origin"))?;
266+
let host = parsed
267+
.host_str()
268+
.ok_or_else(|| ValidationError::new("invalid_aps_inventory_page_origin"))?
269+
.to_ascii_lowercase();
270+
let domain = domain.to_ascii_lowercase();
271+
if host != domain
272+
&& !host
273+
.strip_suffix(&domain)
274+
.is_some_and(|prefix| prefix.ends_with('.'))
275+
{
276+
return Err(ValidationError::new("aps_inventory_origin_domain_mismatch"));
277+
}
278+
Ok(())
279+
}
280+
200281
fn default_enabled() -> bool {
201282
false
202283
}
@@ -217,6 +298,8 @@ impl Default for ApsConfig {
217298
endpoint: default_endpoint(),
218299
timeout_ms: default_timeout_ms(),
219300
allow_script_creatives: false,
301+
inventory_domain: None,
302+
inventory_page_origin: None,
220303
}
221304
}
222305
}
@@ -324,6 +407,28 @@ impl ApsAuctionProvider {
324407
Some(parsed.to_string())
325408
}
326409

410+
fn inventory_site_identity(
411+
&self,
412+
fallback_domain: &str,
413+
fallback_page: String,
414+
) -> (String, String) {
415+
let (Some(domain), Some(origin)) = (
416+
self.config.inventory_domain.as_ref(),
417+
self.config.inventory_page_origin.as_deref(),
418+
) else {
419+
return (fallback_domain.to_string(), fallback_page);
420+
};
421+
let (Ok(mut canonical_page), Ok(current_page)) =
422+
(Url::parse(origin), Url::parse(&fallback_page))
423+
else {
424+
return (fallback_domain.to_string(), fallback_page);
425+
};
426+
canonical_page.set_path(current_page.path());
427+
canonical_page.set_query(current_page.query());
428+
canonical_page.set_fragment(None);
429+
(domain.clone(), canonical_page.to_string())
430+
}
431+
327432
fn build_openrtb_request(
328433
&self,
329434
request: &AuctionRequest,
@@ -420,12 +525,13 @@ impl ApsAuctionProvider {
420525
.and_then(|value| value.to_str().ok())
421526
.and_then(Self::valid_http_url)
422527
.filter(|value| value != &page);
528+
let (site_domain, page) = self.inventory_site_identity(&request.publisher.domain, page);
423529

424530
OpenRtbRequest {
425531
id: Some(request.id.clone()),
426532
imp,
427533
site: Some(Site {
428-
domain: Some(request.publisher.domain.clone()),
534+
domain: Some(site_domain.clone()),
429535
page: Some(page),
430536
r#ref: referer,
431537
publisher: Some(Publisher {
@@ -956,6 +1062,8 @@ mod tests {
9561062
endpoint: default_endpoint(),
9571063
timeout_ms: 800,
9581064
allow_script_creatives: false,
1065+
inventory_domain: None,
1066+
inventory_page_origin: None,
9591067
}
9601068
}
9611069

@@ -1052,6 +1160,93 @@ mod tests {
10521160
}
10531161
}
10541162

1163+
#[test]
1164+
fn config_requires_safe_inventory_identity_override_pair() {
1165+
for value in [
1166+
json!({
1167+
"account_id": "example-account",
1168+
"inventory_domain": "publisher.example"
1169+
}),
1170+
json!({
1171+
"account_id": "example-account",
1172+
"inventory_page_origin": "https://www.publisher.example"
1173+
}),
1174+
json!({
1175+
"account_id": "example-account",
1176+
"inventory_domain": "publisher.example",
1177+
"inventory_page_origin": "http://www.publisher.example"
1178+
}),
1179+
json!({
1180+
"account_id": "example-account",
1181+
"inventory_domain": "publisher.example",
1182+
"inventory_page_origin": "https://www.publisher.example/path"
1183+
}),
1184+
json!({
1185+
"account_id": "example-account",
1186+
"inventory_domain": "publisher.example/path",
1187+
"inventory_page_origin": "https://www.publisher.example"
1188+
}),
1189+
json!({
1190+
"account_id": "example-account",
1191+
"inventory_domain": "publisher.example",
1192+
"inventory_page_origin": "https://unrelated.example"
1193+
}),
1194+
] {
1195+
let parsed: ApsConfig =
1196+
serde_json::from_value(value).expect("should deserialize before validation");
1197+
assert!(
1198+
parsed.validate().is_err(),
1199+
"should reject unsafe or incomplete inventory identity override"
1200+
);
1201+
}
1202+
}
1203+
1204+
#[test]
1205+
fn inventory_identity_override_rewrites_site_and_preserves_page_path() {
1206+
let config: ApsConfig = serde_json::from_value(json!({
1207+
"enabled": true,
1208+
"account_id": "example-account",
1209+
"inventory_domain": "publisher.example",
1210+
"inventory_page_origin": "https://www.publisher.example"
1211+
}))
1212+
.expect("should deserialize APS inventory identity override");
1213+
config
1214+
.validate()
1215+
.expect("should validate APS inventory identity override");
1216+
let provider = ApsAuctionProvider::new(config);
1217+
let mut auction_request = request();
1218+
auction_request.publisher.domain = "deployment.example".to_string();
1219+
auction_request.publisher.page_url =
1220+
Some("https://deployment.example/news/story?edition=fictional#section".to_string());
1221+
let settings = create_test_settings();
1222+
let services = noop_services();
1223+
let downstream = http::Request::builder()
1224+
.uri("https://deployment.example/auction")
1225+
.body(EdgeBody::empty())
1226+
.expect("should build downstream request");
1227+
let context = AuctionContext {
1228+
settings: &settings,
1229+
request: &downstream,
1230+
timeout_ms: 321,
1231+
provider_responses: None,
1232+
services: &services,
1233+
};
1234+
1235+
let serialized =
1236+
serde_json::to_value(provider.build_openrtb_request(&auction_request, &context))
1237+
.expect("should serialize APS request");
1238+
1239+
assert_eq!(serialized["site"]["domain"], "publisher.example");
1240+
assert_eq!(
1241+
serialized["site"]["page"],
1242+
"https://www.publisher.example/news/story?edition=fictional"
1243+
);
1244+
assert_eq!(
1245+
serialized["site"]["publisher"]["domain"],
1246+
"deployment.example"
1247+
);
1248+
}
1249+
10551250
#[test]
10561251
fn builds_aps_openrtb_request_with_explicit_privacy_policy() {
10571252
let provider = ApsAuctionProvider::new(config());

0 commit comments

Comments
 (0)