Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Creative rewrite proxy, click, CSS, `srcset`, sign-response, rebuild-response, and injected creative TSJS URLs are now absolute on optional `publisher.public_origin` (falling back to `https://{publisher.domain}`). Existing root-relative signed proxy and click inputs remain accepted.
- **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.
- **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.

Expand Down
2 changes: 1 addition & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ fn named_routes() -> [NamedRoute; 12] {
},
NamedRoute {
path: "/first-party/proxy-rebuild",
primary_methods: &[Method::POST],
primary_methods: &[Method::GET, Method::POST],
handler: NamedRouteHandler::FirstPartyProxyRebuild,
},
]
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ fn all_explicit_routes_are_registered() {
("GET", "/first-party/click"),
("GET", "/first-party/sign"),
("POST", "/first-party/sign"),
("GET", "/first-party/proxy-rebuild"),
("POST", "/first-party/proxy-rebuild"),
];

Expand Down
6 changes: 6 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,12 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
handle_first_party_proxy_sign(&s.settings, &services, req).await
}),
)
.get(
"/first-party/proxy-rebuild",
make_handler(Arc::clone(&state), |s, services, req| async move {
handle_first_party_proxy_rebuild(&s.settings, &services, req).await
}),
)
.post(
"/first-party/proxy-rebuild",
make_handler(Arc::clone(&state), |s, services, req| async move {
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-adapter-cloudflare/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ fn all_explicit_routes_are_registered() {
("GET", "/first-party/click"),
("GET", "/first-party/sign"),
("POST", "/first-party/sign"),
("GET", "/first-party/proxy-rebuild"),
("POST", "/first-party/proxy-rebuild"),
];

Expand Down
18 changes: 16 additions & 2 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
//! | GET | `/first-party/click` | [`handle_first_party_click`] |
//! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] |
//! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] |
//! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] |
//! | GET, POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] |
//! | GET | `/` and `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback |
//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | `/` and `/{*rest}` | integration proxy or publisher fallback |
//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | named paths above | publisher fallback (legacy parity for non-primary methods) |
Expand Down Expand Up @@ -1104,7 +1104,7 @@ const NAMED_ROUTES: &[NamedRoute] = &[
},
NamedRoute {
path: "/first-party/proxy-rebuild",
primary_methods: &[Method::POST],
primary_methods: &[Method::GET, Method::POST],
handler: NamedRouteHandler::FirstPartyProxyRebuild,
},
];
Expand Down Expand Up @@ -1561,6 +1561,20 @@ mod tests {
);
}

#[test]
fn proxy_rebuild_registers_get_and_post() {
let route = NAMED_ROUTES
.iter()
.find(|route| route.path == "/first-party/proxy-rebuild")
.expect("should register proxy rebuild route");
assert!(matches!(
route.handler,
NamedRouteHandler::FirstPartyProxyRebuild
));
assert!(route.primary_methods.contains(&Method::GET));
assert!(route.primary_methods.contains(&Method::POST));
}

#[test]
fn legacy_admin_aliases_route_to_local_deny_not_key_handlers() {
// Security guard for the legacy non-`/_ts` admin aliases. They must be
Expand Down
8 changes: 5 additions & 3 deletions crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] {
("/first-party/proxy", &[Method::GET]),
("/first-party/click", &[Method::GET]),
("/first-party/sign", &[Method::GET, Method::POST]),
("/first-party/proxy-rebuild", &[Method::POST]),
("/first-party/proxy-rebuild", &[Method::GET, Method::POST]),
]
}

Expand Down Expand Up @@ -608,7 +608,7 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
};
let fp_sign_post_handler = fp_sign_handler.clone();

// /first-party/proxy-rebuild
// GET + POST /first-party/proxy-rebuild — identical handler, cloned for both bindings.
let s = Arc::clone(&state);
let fp_rebuild_handler = move |ctx: RequestContext| {
let s = Arc::clone(&s);
Expand All @@ -622,6 +622,7 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
)
}
};
let fp_rebuild_post_handler = fp_rebuild_handler.clone();

// Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher.
async fn dispatch(
Expand Down Expand Up @@ -741,7 +742,8 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
.get("/first-party/click", fp_click_handler)
.get("/first-party/sign", fp_sign_handler)
.post("/first-party/sign", fp_sign_post_handler)
.post("/first-party/proxy-rebuild", fp_rebuild_handler);
.get("/first-party/proxy-rebuild", fp_rebuild_handler)
.post("/first-party/proxy-rebuild", fp_rebuild_post_handler);

for method in LEGACY_ADMIN_DENY_METHODS {
builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny);
Expand Down
38 changes: 29 additions & 9 deletions crates/trusted-server-adapter-spin/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,22 @@ async fn first_party_proxy_rebuild_is_routed() {
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn first_party_proxy_rebuild_get_is_routed() {
let router = test_router();
let req = request_builder()
.method("GET")
.uri("/first-party/proxy-rebuild")
.body(edgezero_core::body::Body::empty())
.expect("should build request");
let resp = route(router, req).await;
assert_ne!(
resp.status().as_u16(),
404,
"GET /first-party/proxy-rebuild must be routed"
);
}

// ---------------------------------------------------------------------------
// First-party absolute-URI regression — Spin delivers a path-only request URI
// (built from IncomingRequest::path_with_query), so the shared proxy/click/sign
Expand Down Expand Up @@ -548,21 +564,25 @@ async fn first_party_proxy_round_trip_through_spin_router() {
.to_vec(),
)
.expect("sign response body should be UTF-8");
let href = json_string_field(&sign_body, "href")
.expect("sign response must include a signed href path");
let href =
json_string_field(&sign_body, "href").expect("sign response must include a signed href");
let (_, authority_and_path) = href
.split_once("://")
.expect("signed href should be absolute");
let (_, path_and_query) = authority_and_path
.split_once('/')
.expect("signed href should include a path");
let href_path_and_query = format!("/{path_and_query}");
assert!(
href.starts_with("/first-party/proxy?"),
"signed href must target the proxy path, got: {href}"
href_path_and_query.starts_with("/first-party/proxy?"),
"signed href must target the proxy path"
);

let router = test_router();
let proxy_req = request_builder()
.method("GET")
.uri(href.clone())
.header(
"spin-full-url",
format!("https://www.publisher.example{href}"),
)
.uri(href_path_and_query)
.header("spin-full-url", href)
.body(edgezero_core::body::Body::empty())
.expect("should build proxy request");
let proxy_resp = route(router, proxy_req).await;
Expand Down
4 changes: 2 additions & 2 deletions crates/trusted-server-core/src/auction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ The orchestrator collects all bids and creates an OpenRTB response:
}
```

Note that creative HTML is rewritten to use the first-party proxy (`/first-party/proxy`) for privacy and security.
Note that creative HTML is rewritten to use the configured browser-facing public origin, for example `https://ads.publisher.example/first-party/proxy`, for privacy and security.

## Route Registration & Endpoints

Expand All @@ -262,7 +262,7 @@ The trusted-server handles several types of routes defined in `crates/trusted-se
| `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 84 |
| `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 85 |
| `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 86 |
| `/first-party/proxy-rebuild` | POST | `handle_first_party_proxy_rebuild()` | Rebuild creative HTML with new settings | 89 |
| `/first-party/proxy-rebuild` | GET/POST | `handle_first_party_proxy_rebuild()` | Rebuild signed creative click URLs | 89 |
| `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 66 |
| `/.well-known/ts.jwks.json` | GET | `handle_jwks_endpoint()` | Public key distribution for request signing | 71 |
| `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 74 |
Expand Down
23 changes: 23 additions & 0 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,29 @@ mod tests {
);
}

#[test]
fn convert_to_openrtb_response_uses_public_origin_for_creative_rewrites() {
let mut settings = make_settings();
settings.publisher.public_origin = Some("https://ads.publisher.example:8443".to_string());
let auction_request = make_auction_request();
let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75));
bid.creative = Some(
r#"<body><img src="https://cdn.example.com/ad.png"><a href="https://advertiser.example.com/landing">ad</a></body>"#
.to_string(),
);
let result = make_result(bid);

let response = convert_to_openrtb_response(&result, &settings, &auction_request, true)
.expect("should convert rewritten creative");
let json = response_json(response);
let adm = json["seatbid"][0]["bid"][0]["adm"]
.as_str()
.expect("should serialize creative HTML");
assert!(adm.contains("https://ads.publisher.example:8443/first-party/proxy?"));
assert!(adm.contains("https://ads.publisher.example:8443/first-party/click?"));
assert!(adm.contains("https://ads.publisher.example:8443/static/tsjs="));
}

#[test]
fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() {
let settings = make_settings();
Expand Down
21 changes: 21 additions & 0 deletions crates/trusted-server-core/src/config_payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ mod tests {
);
}

#[test]
fn legacy_blob_without_public_origin_uses_domain_fallback() {
let mut original = test_settings();
original.publisher.public_origin = Some("https://ads.example.com:8443".to_string());
let mut data = serde_json::to_value(&original).expect("should serialize settings to JSON");
data["publisher"]
.as_object_mut()
.expect("should serialize publisher as an object")
.remove("public_origin");
let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string());
let json = serde_json::to_string(&envelope).expect("should serialize legacy envelope");

let reconstructed = settings_from_config_blob(&json)
.expect("should reconstruct settings without public_origin");
assert_eq!(
reconstructed.publisher.effective_public_origin(),
format!("https://{}", original.publisher.domain),
"should fall back to the publisher domain"
);
}

#[test]
fn strings_that_look_like_json_scalars_round_trip_as_strings() {
let mut original = test_settings();
Expand Down
71 changes: 61 additions & 10 deletions crates/trusted-server-core/src/creative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//!
//! Key behaviors:
//! - Absolute and protocol-relative URLs (http/https or `//`) are proxied to
//! `/first-party/proxy?tsurl=<base-url>&<original-query-params>&tstoken=<sig>` across these locations:
//! `{public_origin}/first-party/proxy?tsurl=<base-url>&<original-query-params>&tstoken=<sig>` across these locations:
//! - `<img src>`, `data-src`, `[srcset]`, `[imagesrcset]`
//! - `<script src>`
//! - `<video src>`, `<audio src>`, `<source src>`
Expand Down Expand Up @@ -140,6 +140,13 @@ pub(super) fn rewrite_style_urls(settings: &Settings, style: &str) -> String {
out
}

/// Prefixes a controlled root-relative Trusted Server endpoint with its browser-facing origin.
#[inline]
pub(crate) fn first_party_url(settings: &Settings, path: &str) -> String {
debug_assert!(path.starts_with('/'));
format!("{}{}", settings.publisher.effective_public_origin(), path)
}

#[inline]
fn build_signed_url_for(
settings: &Settings,
Expand Down Expand Up @@ -183,7 +190,7 @@ fn build_signed_url_for(
qs.append_pair(k, v);
}
qs.append_pair("tstoken", &token);
format!("{}?{}", base_path, qs.finish())
first_party_url(settings, &format!("{}?{}", base_path, qs.finish()))
}

#[inline]
Expand Down Expand Up @@ -493,11 +500,11 @@ pub fn sanitize_creative_html(markup: &str) -> String {
}

/// Rewrite ad creative HTML to first-party endpoints.
/// - 1x1 `<img>` pixels → `/first-party/proxy?tsurl=&lt;base-url&gt;&lt;params&gt;&tstoken=&lt;sig&gt;`
/// - Non-pixel absolute images → `/first-party/proxy?tsurl=&lt;base-url&gt;&lt;params&gt;&tstoken=&lt;sig&gt;`
/// - `<iframe src>` (absolute or protocol-relative) → `/first-party/proxy?tsurl=&lt;base-url&gt;&lt;params&gt;&tstoken=&lt;sig&gt;`
/// - 1x1 `<img>` pixels → `{public_origin}/first-party/proxy?tsurl=&lt;base-url&gt;&lt;params&gt;&tstoken=&lt;sig&gt;`
/// - Non-pixel absolute images → `{public_origin}/first-party/proxy?tsurl=&lt;base-url&gt;&lt;params&gt;&tstoken=&lt;sig&gt;`
/// - `<iframe src>` (absolute or protocol-relative) → `{public_origin}/first-party/proxy?tsurl=&lt;base-url&gt;&lt;params&gt;&tstoken=&lt;sig&gt;`
/// - Injects the `tsjs-creative` script once at the top of `<body>` to safeguard click URLs inside creatives
/// (served from `/static/tsjs=tsjs-creative.min.js`).
/// (served from `{public_origin}/static/tsjs=tsjs-creative.min.js`).
#[must_use]
pub fn rewrite_creative_html(settings: &Settings, markup: &str) -> String {
// No size parsing needed now; all absolute/protocol-relative URLs are proxied uniformly.
Expand All @@ -511,7 +518,11 @@ pub fn rewrite_creative_html(settings: &Settings, markup: &str) -> String {
let injected = injected_ts_creative.clone();
move |el| {
if !injected.get() {
let script_tag = tsjs::tsjs_unified_script_tag();
let script_src = tsjs::tsjs_unified_script_src();
let script_tag = format!(
"<script src=\"{}\" id=\"trustedserver-js\"></script>",
first_party_url(settings, &script_src)
);
el.prepend(&script_tag, ContentType::Html);
injected.set(true);
}
Expand Down Expand Up @@ -769,7 +780,8 @@ impl StreamProcessor for CreativeCssProcessor<'_> {
#[cfg(test)]
mod tests {
use super::{
rewrite_creative_html, rewrite_srcset, rewrite_style_urls, sanitize_creative_html, to_abs,
build_click_url, build_proxy_url, rewrite_creative_html, rewrite_srcset,
rewrite_style_urls, sanitize_creative_html, to_abs,
};

fn rewrite_srcset_attr(attr_name: &str, attr_value: &str) -> String {
Expand Down Expand Up @@ -1306,7 +1318,7 @@ mod tests {
<img data-srcset="https://cdn.example/img-1x.png 1x, //cdn.example/img-2x.png 2x, /local/img.png 1x">
"#;
let out = rewrite_creative_html(&settings, html);
assert!(out.contains("data-src=\"/first-party/proxy?tsurl="));
assert!(out.contains("data-src=\"https://test-publisher.com/first-party/proxy?tsurl="));
assert!(out.matches("/first-party/proxy?tsurl=").count() >= 1);
// relative candidate remains
assert!(out.contains("/local/img.png 1x"));
Expand Down Expand Up @@ -1439,7 +1451,46 @@ mod tests {
// Non-excluded should be rewritten and SHOULD have data-tsclick
assert!(out.contains("/first-party/click?tsurl="));
assert!(out.contains("advertiser.example.com"));
assert!(out.contains("data-tsclick=\"/first-party/click"));
assert!(out.contains("data-tsclick=\"https://test-publisher.com/first-party/click"));
}

#[test]
fn generated_first_party_urls_use_public_origin() {
let mut settings = crate::test_support::tests::create_test_settings();
settings.publisher.public_origin = Some("https://ads.publisher.example:8443".to_string());

let proxy = build_proxy_url(&settings, "https://cdn.example.com/ad.png?campaign=1");
let click = build_click_url(&settings, "https://advertiser.example.com/landing");
for generated in [&proxy, &click] {
let parsed = url::Url::parse(generated).expect("should build an absolute URL");
assert_eq!(
parsed.origin().ascii_serialization(),
"https://ads.publisher.example:8443"
);
}

let out = rewrite_creative_html(
&settings,
r#"<body><img src="https://cdn.example.com/ad.png" srcset="https://cdn.example.com/ad-2x.png 2x" style="background:url(https://cdn.example.com/bg.png)"><a href="https://advertiser.example.com/landing">ad</a></body>"#,
);
assert!(out.contains("https://ads.publisher.example:8443/first-party/proxy?"));
assert!(out.contains("https://ads.publisher.example:8443/first-party/click?"));
assert_eq!(
out.matches("https://ads.publisher.example:8443/first-party/proxy?")
.count(),
3,
"should qualify resource, srcset, and CSS rewrites"
);
assert!(out.contains("https://ads.publisher.example:8443/static/tsjs="));
assert!(!out.contains("origin.test-publisher.com"));
assert!(!out.contains("https://test-publisher.com/first-party"));
}

#[test]
fn generated_first_party_urls_fall_back_to_publisher_domain() {
let settings = crate::test_support::tests::create_test_settings();
let proxy = build_proxy_url(&settings, "https://cdn.example.com/ad.png");
assert!(proxy.starts_with("https://test-publisher.com/first-party/proxy?"));
}

// ── sanitize_creative_html tests ────────────────────────────────────────
Expand Down
Loading
Loading