Skip to content

Commit 6fcbee0

Browse files
Address creative rewriting review feedback
1 parent 6b4e82e commit 6fcbee0

10 files changed

Lines changed: 310 additions & 46 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Regression coverage for typed app-config environment overlays.
2+
3+
use std::fs;
4+
use std::process::{Command, Output};
5+
6+
use tempfile::TempDir;
7+
use toml_edit::{DocumentMut, value};
8+
9+
const LEGACY_CONFIG: &str = include_str!(
10+
"../../trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml"
11+
);
12+
const MANIFEST: &str = r#"
13+
[app]
14+
name = "trusted-server"
15+
16+
[adapters.axum.adapter]
17+
crate = "crates/trusted-server-adapter-axum"
18+
19+
[adapters.axum.commands]
20+
build = "echo"
21+
deploy = "echo"
22+
serve = "echo"
23+
24+
[stores.config]
25+
ids = ["trusted_server_config"]
26+
27+
[stores.secrets]
28+
ids = ["trusted_server_secrets"]
29+
"#;
30+
const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES";
31+
32+
struct MigratedProject {
33+
directory: TempDir,
34+
config_path: std::path::PathBuf,
35+
manifest_path: std::path::PathBuf,
36+
}
37+
38+
fn migrated_legacy_project() -> MigratedProject {
39+
let directory = tempfile::tempdir().expect("should create temporary config directory");
40+
let config_path = directory.path().join("trusted-server.toml");
41+
let manifest_path = directory.path().join("edgezero.toml");
42+
let mut document = LEGACY_CONFIG
43+
.parse::<DocumentMut>()
44+
.expect("should parse legacy integration config");
45+
document["auction"]["rewrite_creatives"] = value(true);
46+
fs::write(&config_path, document.to_string()).expect("should write migrated config");
47+
fs::write(&manifest_path, MANIFEST).expect("should write test manifest");
48+
MigratedProject {
49+
directory,
50+
config_path,
51+
manifest_path,
52+
}
53+
}
54+
55+
fn validate_with_overlay(project: &MigratedProject, raw_value: &str) -> Output {
56+
Command::new(env!("CARGO_BIN_EXE_ts"))
57+
.args(["config", "validate", "--manifest"])
58+
.arg(&project.manifest_path)
59+
.arg("--app-config")
60+
.arg(&project.config_path)
61+
.env(REWRITE_ENV, raw_value)
62+
.output()
63+
.expect("should run ts config validate")
64+
}
65+
66+
#[test]
67+
fn migrated_legacy_config_applies_rewrite_creatives_environment_override() {
68+
let project = migrated_legacy_project();
69+
let output = Command::new(env!("CARGO_BIN_EXE_ts"))
70+
.args(["config", "push", "--adapter", "axum", "--manifest"])
71+
.arg(&project.manifest_path)
72+
.arg("--app-config")
73+
.arg(&project.config_path)
74+
.args(["--yes", "--no-diff"])
75+
.env(REWRITE_ENV, "false")
76+
.output()
77+
.expect("should run ts config push");
78+
79+
assert!(
80+
output.status.success(),
81+
"valid boolean overlay should push successfully: {}",
82+
String::from_utf8_lossy(&output.stderr)
83+
);
84+
85+
let local_store_path = project
86+
.directory
87+
.path()
88+
.join(".edgezero/local-config-trusted_server_config.json");
89+
let local_store: serde_json::Value = serde_json::from_str(
90+
&fs::read_to_string(local_store_path).expect("should read pushed local config"),
91+
)
92+
.expect("should parse local config store");
93+
let envelope_json = local_store
94+
.as_object()
95+
.and_then(|entries| entries.values().next())
96+
.and_then(serde_json::Value::as_str)
97+
.expect("should contain a blob envelope");
98+
let envelope: serde_json::Value =
99+
serde_json::from_str(envelope_json).expect("should parse blob envelope");
100+
101+
assert_eq!(
102+
envelope["data"]["auction"]["rewrite_creatives"],
103+
serde_json::Value::Bool(false),
104+
"pushed config should contain the environment override"
105+
);
106+
}
107+
108+
#[test]
109+
fn migrated_legacy_config_rejects_invalid_rewrite_creatives_environment_override() {
110+
let project = migrated_legacy_project();
111+
let output = validate_with_overlay(&project, "not-a-boolean");
112+
let stderr = String::from_utf8_lossy(&output.stderr);
113+
114+
assert!(
115+
!output.status.success(),
116+
"invalid boolean overlay should fail validation"
117+
);
118+
assert!(
119+
stderr.contains(REWRITE_ENV) && stderr.contains("boolean"),
120+
"error should identify the invalid boolean overlay: {stderr}"
121+
);
122+
}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ When a request arrives at the `/auction` endpoint, it goes through the following
136136
┌──────────────────────────────────────────────────────────────────────┐
137137
│ 11. Transform to OpenRTB Response (mod.rs:274-322) │
138138
│ - Build seatbid array (one per winning bid) │
139-
│ - Rewrite creative HTML for first-party proxy │
139+
│ - Always sanitize creative HTML │
140+
│ - Rewrite creative HTML when enabled (default) │
140141
│ - Add orchestrator metadata (timing, strategy, bid count) │
141142
└──────────────────────────────────────────────────────────────────────┘
142143
@@ -248,7 +249,10 @@ The orchestrator collects all bids and creates an OpenRTB response:
248249
}
249250
```
250251

251-
Note that creative HTML is rewritten to use the first-party proxy (`/first-party/proxy`) for privacy and security.
252+
Creative HTML is always sanitized. By default, it is then rewritten to use the
253+
first-party proxy (`/first-party/proxy`) and the creative runtime is injected.
254+
Setting `[auction].rewrite_creatives = false` skips only that rewrite and
255+
injection pass.
252256

253257
## Route Registration & Endpoints
254258

@@ -379,7 +383,8 @@ The `/auction` endpoint is the primary entry point for auctions:
379383
**Key Transformations:**
380384
- `adUnits[].code``seatbid[].bid[].impid` (slot identifier)
381385
- `mediaTypes.banner.sizes` → evaluated by providers, winning size in `bid.w` and `bid.h`
382-
- Creative HTML is rewritten to use `/first-party/proxy` URLs
386+
- Creative HTML is always sanitized, then rewritten to use `/first-party/proxy` URLs by default
387+
- `[auction].rewrite_creatives = false` skips rewriting and creative runtime injection, not sanitization
383388
- Multiple bids per slot become separate `seatbid` entries
384389
- Orchestrator metadata added in `ext.orchestrator`
385390

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ pub struct AuctionConfig {
1212
pub enabled: bool,
1313

1414
/// Rewrite sanitized winning-bid creative HTML to first-party endpoints.
15-
#[serde(default = "default_rewrite_creatives")]
15+
#[serde(
16+
default = "default_rewrite_creatives",
17+
skip_serializing_if = "is_default_rewrite_creatives"
18+
)]
1619
pub rewrite_creatives: bool,
1720

1821
/// Provider names that participate in bidding
@@ -63,6 +66,10 @@ fn default_rewrite_creatives() -> bool {
6366
true
6467
}
6568

69+
fn is_default_rewrite_creatives(value: &bool) -> bool {
70+
*value == default_rewrite_creatives()
71+
}
72+
6673
fn default_creative_store() -> String {
6774
"creative_store".to_owned()
6875
}
@@ -95,9 +102,38 @@ mod tests {
95102

96103
#[test]
97104
fn rewrite_creatives_defaults_to_true() {
105+
let config: AuctionConfig =
106+
serde_json::from_value(serde_json::json!({})).expect("should deserialize defaults");
107+
98108
assert!(
99-
AuctionConfig::default().rewrite_creatives,
109+
config.rewrite_creatives,
100110
"should enable creative rewriting by default"
101111
);
102112
}
113+
114+
#[test]
115+
fn default_rewrite_creatives_is_not_serialized() {
116+
let serialized =
117+
serde_json::to_value(AuctionConfig::default()).expect("should serialize defaults");
118+
119+
assert!(
120+
serialized.get("rewrite_creatives").is_none(),
121+
"should omit the default rewrite setting"
122+
);
123+
}
124+
125+
#[test]
126+
fn disabled_rewrite_creatives_is_serialized() {
127+
let config = AuctionConfig {
128+
rewrite_creatives: false,
129+
..AuctionConfig::default()
130+
};
131+
let serialized = serde_json::to_value(config).expect("should serialize disabled rewriting");
132+
133+
assert_eq!(
134+
serialized.get("rewrite_creatives"),
135+
Some(&serde_json::Value::Bool(false)),
136+
"should preserve an explicit rewrite opt-out"
137+
);
138+
}
103139
}

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

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ mod tests {
4646
use super::*;
4747
use crate::redacted::Redacted;
4848
use crate::test_support::tests::crate_test_settings_str;
49+
use serde::Deserialize;
50+
51+
#[derive(Deserialize)]
52+
#[serde(deny_unknown_fields)]
53+
struct LegacyAuctionConfig {
54+
#[serde(rename = "enabled")]
55+
_enabled: bool,
56+
#[serde(rename = "providers")]
57+
_providers: Vec<String>,
58+
#[serde(rename = "mediator")]
59+
_mediator: Option<String>,
60+
#[serde(rename = "timeout_ms")]
61+
_timeout_ms: u32,
62+
#[serde(rename = "creative_store")]
63+
_creative_store: String,
64+
#[serde(rename = "allowed_context_keys")]
65+
_allowed_context_keys: std::collections::HashSet<String>,
66+
}
4967

5068
fn test_settings() -> Settings {
5169
Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings")
@@ -80,15 +98,15 @@ mod tests {
8098

8199
#[test]
82100
fn legacy_blob_without_rewrite_creatives_preserves_rewriting() {
83-
let mut data =
101+
let data =
84102
serde_json::to_value(test_settings()).expect("should serialize settings to JSON");
85103
let auction = data
86-
.get_mut("auction")
87-
.and_then(serde_json::Value::as_object_mut)
104+
.get("auction")
105+
.and_then(serde_json::Value::as_object)
88106
.expect("should serialize auction settings as an object");
89107
assert!(
90-
auction.remove("rewrite_creatives").is_some(),
91-
"should remove the newly serialized setting"
108+
!auction.contains_key("rewrite_creatives"),
109+
"should omit the default rewrite setting from the payload"
92110
);
93111
let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string());
94112
let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope");
@@ -102,6 +120,33 @@ mod tests {
102120
);
103121
}
104122

123+
#[test]
124+
fn default_auction_payload_is_accepted_by_legacy_schema() {
125+
let data =
126+
serde_json::to_value(test_settings()).expect("should serialize settings to JSON");
127+
let auction = data
128+
.get("auction")
129+
.cloned()
130+
.expect("should serialize auction settings");
131+
132+
serde_json::from_value::<LegacyAuctionConfig>(auction)
133+
.expect("should deserialize the default payload with the legacy schema");
134+
}
135+
136+
#[test]
137+
fn disabled_rewrite_creatives_survives_blob_round_trip() {
138+
let mut original = test_settings();
139+
original.auction.rewrite_creatives = false;
140+
141+
let reconstructed = settings_from_config_blob(&envelope_json(&original))
142+
.expect("should reconstruct disabled rewriting");
143+
144+
assert!(
145+
!reconstructed.auction.rewrite_creatives,
146+
"should preserve the explicit rewrite opt-out"
147+
);
148+
}
149+
105150
#[test]
106151
fn strings_that_look_like_json_scalars_round_trip_as_strings() {
107152
let mut original = test_settings();

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

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,30 +56,21 @@ pub(super) fn to_abs(settings: &Settings, u: &str) -> Option<String> {
5656
return None;
5757
}
5858

59-
// Skip if excluded from rewrites in settings
60-
if settings.rewrite.is_excluded(t) {
59+
let lower = t.to_ascii_lowercase();
60+
let absolute = if t.starts_with("//") {
61+
format!("https:{t}")
62+
} else if lower.starts_with("http://") || lower.starts_with("https://") {
63+
t.to_owned()
64+
} else {
6165
return None;
62-
}
66+
};
6367

64-
// Skip non-network schemes commonly found in creatives
65-
let lower = t.to_ascii_lowercase();
66-
if lower.starts_with("data:")
67-
|| lower.starts_with("javascript:")
68-
|| lower.starts_with("mailto:")
69-
|| lower.starts_with("tel:")
70-
|| lower.starts_with("blob:")
71-
|| lower.starts_with("about:")
72-
{
68+
// Match exclusions against the same absolute URL used for rewriting.
69+
if settings.rewrite.is_excluded(&absolute) {
7370
return None;
7471
}
7572

76-
if t.starts_with("//") {
77-
Some(format!("https:{t}"))
78-
} else if lower.starts_with("http://") || lower.starts_with("https://") {
79-
Some(t.to_owned())
80-
} else {
81-
None
82-
}
73+
Some(absolute)
8374
}
8475

8576
// Helper: rewrite url(...) occurrences inside a CSS style string to first-party proxy.
@@ -1323,11 +1314,22 @@ mod tests {
13231314
None
13241315
);
13251316

1317+
assert_eq!(
1318+
to_abs(&settings, "//trusted-cdn.example.com/lib.js"),
1319+
None,
1320+
"should exclude a protocol-relative URL by exact domain"
1321+
);
1322+
13261323
// Non-excluded domain should return Some
13271324
assert_eq!(
13281325
to_abs(&settings, "https://other-cdn.example.com/lib.js"),
13291326
Some("https://other-cdn.example.com/lib.js".to_owned())
13301327
);
1328+
assert_eq!(
1329+
to_abs(&settings, "//other-cdn.example.com/lib.js"),
1330+
Some("https://other-cdn.example.com/lib.js".to_owned()),
1331+
"should normalize a non-excluded protocol-relative URL"
1332+
);
13311333
}
13321334

13331335
#[test]
@@ -1343,6 +1345,16 @@ mod tests {
13431345
to_abs(&settings, "https://cdnjs.cloudflare.com/lib.js"),
13441346
None
13451347
);
1348+
assert_eq!(
1349+
to_abs(&settings, "//cloudflare.com/cdn.js"),
1350+
None,
1351+
"should exclude a protocol-relative wildcard base domain"
1352+
);
1353+
assert_eq!(
1354+
to_abs(&settings, "//cdnjs.cloudflare.com/lib.js"),
1355+
None,
1356+
"should exclude a protocol-relative wildcard subdomain"
1357+
);
13461358

13471359
// Should not exclude different domain
13481360
assert_eq!(
@@ -1358,6 +1370,7 @@ mod tests {
13581370

13591371
let html = r#"
13601372
<img src="https://trusted-cdn.example.com/logo.png">
1373+
<img src="//trusted-cdn.example.com/protocol-relative.png">
13611374
<img src="https://other-cdn.example.com/banner.jpg">
13621375
"#;
13631376

@@ -1366,6 +1379,11 @@ mod tests {
13661379
// Excluded domain should NOT be rewritten
13671380
assert!(out.contains(r#"src="https://trusted-cdn.example.com/logo.png"#));
13681381

1382+
assert!(
1383+
out.contains(r#"src="//trusted-cdn.example.com/protocol-relative.png""#),
1384+
"excluded protocol-relative URL should remain direct: {out}"
1385+
);
1386+
13691387
// Non-excluded domain SHOULD be rewritten
13701388
assert!(out.contains("/first-party/proxy?tsurl="));
13711389
assert!(out.contains("other-cdn.example.com"));

0 commit comments

Comments
 (0)