@@ -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" ) ) ]
117118pub 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
136145fn 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+
200281fn 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