Skip to content

Commit 6b4e82e

Browse files
Make auction creative rewriting optional
Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior.
1 parent 0e5dbb2 commit 6b4e82e

12 files changed

Lines changed: 382 additions & 47 deletions

CHANGELOG.md

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

1919
### Added
2020

21+
- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory `/auction` creative sanitization while skipping first-party resource/click URL rewriting and creative TSJS injection.
2122
- Added Osano consent mirror integration docs and public enablement guidance.
2223
- Implemented basic authentication for configurable endpoint paths (#73)
2324
- Added integrations guide with example `testlight` integration

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024;
7676
/// ## Response
7777
///
7878
/// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's
79-
/// `adm` field after sanitisation and first-party URL rewriting. Response
80-
/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and
81-
/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal).
79+
/// `adm` field after mandatory server-side sanitization. First-party resource
80+
/// and click URL rewriting plus creative TSJS injection are enabled by default;
81+
/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`]
82+
/// to `false` skips only that rewrite pass.
8283
///
8384
/// ## Scroll, refresh, and SPA navigation
8485
///

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

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ pub fn convert_tsjs_to_auction_request(
217217

218218
/// Convert `OrchestrationResult` to `OpenRTB` response format.
219219
///
220-
/// Returns rewritten creative HTML directly in the `adm` field for inline delivery.
220+
/// Always sanitizes creative HTML in the `adm` field and optionally rewrites it
221+
/// according to the auction configuration.
221222
///
222223
/// # Errors
223224
///
@@ -250,21 +251,34 @@ pub fn convert_to_openrtb_response(
250251
let width = to_openrtb_i32(bid.width, "width", &bid_context);
251252
let height = to_openrtb_i32(bid.height, "height", &bid_context);
252253

253-
// Process creative HTML if present - — sanitize dangerous markup first, then rewrite URLs.
254+
// Process creative HTML if present — always sanitize dangerous markup first.
254255
let creative_html = if let Some(ref raw_creative) = bid.creative {
255256
let sanitized = creative::sanitize_creative_html(raw_creative);
256-
let rewritten = creative::rewrite_creative_html(settings, &sanitized);
257+
let sanitized_len = sanitized.len();
258+
let rewrite_creatives = settings.auction.rewrite_creatives;
259+
let processed = if rewrite_creatives {
260+
creative::rewrite_creative_html(settings, &sanitized)
261+
} else {
262+
sanitized
263+
};
264+
let rewrite_mode = if rewrite_creatives {
265+
"enabled"
266+
} else {
267+
"disabled"
268+
};
257269

258270
log::debug!(
259-
"Processed creative for auction {} slot {} ({} → {} {} bytes)",
271+
"Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)",
260272
auction_request.id,
261273
slot_id,
274+
bid.bidder,
275+
rewrite_mode,
262276
raw_creative.len(),
263-
sanitized.len(),
264-
rewritten.len()
277+
sanitized_len,
278+
processed.len()
265279
);
266280

267-
rewritten
281+
processed
268282
} else {
269283
// No creative provided (e.g., from mediation layer that returns iframe URLs)
270284
log::warn!(
@@ -445,6 +459,15 @@ mod tests {
445459
}
446460
}
447461

462+
fn make_complete_creative_bid() -> Bid {
463+
let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
464+
bid.creative = Some(
465+
r#"<html><body><a href="https://advertiser.example.com/landing"><img src="https://cdn.example.com/ad.png" style="background-image:url(https://styles.example.com/bg.png)" onerror="auction-handler-marker()"></a><script>auction-script-marker</script></body></html>"#
466+
.to_string(),
467+
);
468+
bid
469+
}
470+
448471
fn make_result(bid: Bid) -> OrchestrationResult {
449472
OrchestrationResult {
450473
provider_responses: vec![AuctionResponse {
@@ -466,6 +489,13 @@ mod tests {
466489
.expect("should parse JSON response")
467490
}
468491

492+
fn response_adm(response: Response<EdgeBody>) -> String {
493+
response_json(response)["seatbid"][0]["bid"][0]["adm"]
494+
.as_str()
495+
.expect("should serialize adm as a string")
496+
.to_string()
497+
}
498+
469499
fn make_banner_body(config: Option<JsonValue>) -> AdRequest {
470500
AdRequest {
471501
ad_units: vec![AdUnit {
@@ -932,6 +962,103 @@ mod tests {
932962
);
933963
}
934964

965+
#[test]
966+
fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() {
967+
let settings = make_settings();
968+
let auction_request = make_auction_request();
969+
let result = make_result(make_complete_creative_bid());
970+
971+
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
972+
.expect("should convert creative with rewriting enabled");
973+
let adm = response_adm(response);
974+
975+
assert!(
976+
adm.matches("/first-party/proxy?tsurl=").count() >= 2,
977+
"should rewrite image and inline CSS URLs through the proxy: {adm}"
978+
);
979+
assert!(
980+
adm.contains("/first-party/click?tsurl="),
981+
"should rewrite click URLs: {adm}"
982+
);
983+
assert!(
984+
adm.contains("data-tsclick"),
985+
"should add the click guard attribute: {adm}"
986+
);
987+
assert!(
988+
adm.contains("tsjs-unified.min.js"),
989+
"should inject the unified creative runtime: {adm}"
990+
);
991+
assert!(
992+
!adm.contains(r#"src="https://cdn.example.com/ad.png""#),
993+
"should not retain the image URL as a direct attribute: {adm}"
994+
);
995+
assert!(
996+
!adm.contains(r#"href="https://advertiser.example.com/landing""#),
997+
"should not retain the click URL as a direct attribute: {adm}"
998+
);
999+
assert!(
1000+
!adm.contains("url(https://styles.example.com/bg.png)"),
1001+
"should not retain the CSS URL as a direct value: {adm}"
1002+
);
1003+
assert!(
1004+
!adm.contains("auction-script-marker"),
1005+
"should remove malicious script content before rewriting: {adm}"
1006+
);
1007+
assert!(
1008+
!adm.contains("auction-handler-marker") && !adm.contains("onerror"),
1009+
"should remove event handlers before rewriting: {adm}"
1010+
);
1011+
}
1012+
1013+
#[test]
1014+
fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() {
1015+
let mut settings = make_settings();
1016+
settings.auction.rewrite_creatives = false;
1017+
let auction_request = make_auction_request();
1018+
let result = make_result(make_complete_creative_bid());
1019+
1020+
let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
1021+
.expect("should convert creative with rewriting disabled");
1022+
let adm = response_adm(response);
1023+
1024+
assert!(
1025+
adm.contains(r#"src="https://cdn.example.com/ad.png""#),
1026+
"should retain the sanitizer-accepted image URL: {adm}"
1027+
);
1028+
assert!(
1029+
adm.contains(r#"href="https://advertiser.example.com/landing""#),
1030+
"should retain the sanitizer-accepted click URL: {adm}"
1031+
);
1032+
assert!(
1033+
adm.contains("url(https://styles.example.com/bg.png)"),
1034+
"should retain the sanitizer-accepted CSS URL: {adm}"
1035+
);
1036+
assert!(
1037+
!adm.contains("/first-party/proxy"),
1038+
"should not rewrite resource URLs: {adm}"
1039+
);
1040+
assert!(
1041+
!adm.contains("/first-party/click"),
1042+
"should not rewrite click URLs: {adm}"
1043+
);
1044+
assert!(
1045+
!adm.contains("data-tsclick"),
1046+
"should not add the click guard attribute: {adm}"
1047+
);
1048+
assert!(
1049+
!adm.contains("tsjs-unified.min.js"),
1050+
"should not inject the unified creative runtime: {adm}"
1051+
);
1052+
assert!(
1053+
!adm.contains("auction-script-marker"),
1054+
"should still remove malicious script content: {adm}"
1055+
);
1056+
assert!(
1057+
!adm.contains("auction-handler-marker") && !adm.contains("onerror"),
1058+
"should still remove event handlers: {adm}"
1059+
);
1060+
}
1061+
9351062
#[test]
9361063
fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() {
9371064
let settings = make_settings();

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1818,6 +1818,7 @@ mod tests {
18181818
futures::executor::block_on(async {
18191819
let config = AuctionConfig {
18201820
enabled: true,
1821+
rewrite_creatives: true,
18211822
providers: vec![],
18221823
mediator: None,
18231824
timeout_ms: 2000,

crates/trusted-server-core/src/auction_config_types.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ pub struct AuctionConfig {
1111
#[serde(default)]
1212
pub enabled: bool,
1313

14+
/// Rewrite sanitized winning-bid creative HTML to first-party endpoints.
15+
#[serde(default = "default_rewrite_creatives")]
16+
pub rewrite_creatives: bool,
17+
1418
/// Provider names that participate in bidding
1519
/// Simply list the provider names (e.g., ["prebid", "aps"])
1620
#[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")]
@@ -41,6 +45,7 @@ impl Default for AuctionConfig {
4145
fn default() -> Self {
4246
Self {
4347
enabled: false,
48+
rewrite_creatives: default_rewrite_creatives(),
4449
providers: Vec::new(),
4550
mediator: None,
4651
timeout_ms: default_timeout(),
@@ -54,6 +59,10 @@ fn default_timeout() -> u32 {
5459
2000
5560
}
5661

62+
fn default_rewrite_creatives() -> bool {
63+
true
64+
}
65+
5766
fn default_creative_store() -> String {
5867
"creative_store".to_owned()
5968
}
@@ -79,3 +88,16 @@ impl AuctionConfig {
7988
self.mediator.is_some()
8089
}
8190
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
96+
#[test]
97+
fn rewrite_creatives_defaults_to_true() {
98+
assert!(
99+
AuctionConfig::default().rewrite_creatives,
100+
"should enable creative rewriting by default"
101+
);
102+
}
103+
}

crates/trusted-server-core/src/config_payload.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ mod tests {
7878
);
7979
}
8080

81+
#[test]
82+
fn legacy_blob_without_rewrite_creatives_preserves_rewriting() {
83+
let mut data =
84+
serde_json::to_value(test_settings()).expect("should serialize settings to JSON");
85+
let auction = data
86+
.get_mut("auction")
87+
.and_then(serde_json::Value::as_object_mut)
88+
.expect("should serialize auction settings as an object");
89+
assert!(
90+
auction.remove("rewrite_creatives").is_some(),
91+
"should remove the newly serialized setting"
92+
);
93+
let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string());
94+
let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope");
95+
96+
let reconstructed =
97+
settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings");
98+
99+
assert!(
100+
reconstructed.auction.rewrite_creatives,
101+
"should enable creative rewriting for legacy blobs"
102+
);
103+
}
104+
81105
#[test]
82106
fn strings_that_look_like_json_scalars_round_trip_as_strings() {
83107
let mut original = test_settings();

crates/trusted-server-core/src/proxy.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2882,6 +2882,51 @@ mod tests {
28822882
assert_eq!(ct, "text/css; charset=utf-8");
28832883
}
28842884

2885+
#[test]
2886+
fn auction_rewrite_setting_does_not_change_proxied_html_or_css_rewriting() {
2887+
let mut settings = create_test_settings();
2888+
settings.auction.rewrite_creatives = false;
2889+
let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy");
2890+
2891+
let html = r#"<html><body><img src="https://cdn.example/ad.png"></body></html>"#;
2892+
let mut html_response = build_http_response(StatusCode::OK, EdgeBody::from(html));
2893+
html_response.headers_mut().insert(
2894+
header::CONTENT_TYPE,
2895+
HeaderValue::from_static("text/html; charset=utf-8"),
2896+
);
2897+
let html_output = finalize(
2898+
&settings,
2899+
&req,
2900+
"https://cdn.example/creative.html",
2901+
html_response,
2902+
)
2903+
.expect("should finalize proxied HTML");
2904+
let html_body = response_body_string(html_output);
2905+
2906+
let css = "body{background:url(https://cdn.example/bg.png)}";
2907+
let mut css_response = build_http_response(StatusCode::OK, EdgeBody::from(css));
2908+
css_response
2909+
.headers_mut()
2910+
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css"));
2911+
let css_output = finalize(
2912+
&settings,
2913+
&req,
2914+
"https://cdn.example/creative.css",
2915+
css_response,
2916+
)
2917+
.expect("should finalize proxied CSS");
2918+
let css_body = response_body_string(css_output);
2919+
2920+
assert!(
2921+
html_body.contains("/first-party/proxy?tsurl="),
2922+
"should keep rewriting proxied HTML when auction rewriting is disabled: {html_body}"
2923+
);
2924+
assert!(
2925+
css_body.contains("/first-party/proxy?tsurl="),
2926+
"should keep rewriting proxied CSS when auction rewriting is disabled: {css_body}"
2927+
);
2928+
}
2929+
28852930
#[test]
28862931
fn html_response_rewrite_preserves_non_standard_port() {
28872932
// Verify that HTML rewriting preserves non-standard ports in sub-resource URLs.

crates/trusted-server-core/src/settings.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4334,6 +4334,41 @@ origin_host_header_overide = "www.example.com""#,
43344334
assert!(!rewrite.is_excluded(""));
43354335
}
43364336

4337+
#[test]
4338+
fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() {
4339+
let toml_str = crate_test_settings_str()
4340+
+ r#"
4341+
[auction]
4342+
enabled = true
4343+
providers = []
4344+
"#;
4345+
4346+
let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML");
4347+
4348+
assert!(
4349+
settings.auction.rewrite_creatives,
4350+
"should preserve creative rewriting when the setting is omitted"
4351+
);
4352+
}
4353+
4354+
#[test]
4355+
fn test_auction_rewrite_creatives_accepts_explicit_false() {
4356+
let toml_str = crate_test_settings_str()
4357+
+ r#"
4358+
[auction]
4359+
enabled = true
4360+
providers = []
4361+
rewrite_creatives = false
4362+
"#;
4363+
4364+
let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML");
4365+
4366+
assert!(
4367+
!settings.auction.rewrite_creatives,
4368+
"should disable creative rewriting when explicitly configured"
4369+
);
4370+
}
4371+
43374372
#[test]
43384373
fn test_auction_allowed_context_keys_defaults_to_empty() {
43394374
let settings = create_test_settings();

0 commit comments

Comments
 (0)