Skip to content
Open
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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** — Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit `[integrations.*]` settings and use native TOML/typed-config booleans and numbers (for example, `enabled = true`, not `enabled = "true"`); quoted numeric and boolean scalars now fail validation instead of silently converting.
- **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.
- **Breaking** — Auction creative sanitization is now opt-in: the new `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives (the majority of programmatic display) while recording normal impressions. `[auction].rewrite_creatives` keeps its `true` default. The 1 MiB per-creative cap is now enforced in every processing mode, and a supplied creative that processing rejects no longer falls back to PBS Cache coordinates (which would deliver the raw cached `adm`). The creative iframe sandbox no longer grants `allow-same-origin`, restoring origin isolation; rewritten-click recovery from the resulting opaque-origin iframe uses the GET `/first-party/proxy-rebuild` navigation fallback, now registered in every adapter, and dynamic resource signing inside those iframes is disabled pending [#982](https://github.com/IABTechLab/trusted-server/issues/982). Upgrading: binaries that predate `sanitize_creatives` reject a blob carrying it, so upgrade the binary first, then push the config. Rollback: non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized into the config blob and older binaries reject unknown fields — before rolling back to a binary that predates a field, restore its default, push the default-compatible blob, then roll back.
- The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). Two deployment notes: audit `[[handlers]]` for patterns broad enough to cover `/_ts` (for example `^/_ts`), which would put this browser-facing endpoint behind Basic Auth and return `401` to every visitor — scope them to `^/_ts/admin`; and prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. In both cases the shipped client falls back to the deprecated alias, so the exposure is bounded until that alias is removed.

### Security
Expand All @@ -24,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory creative sanitization across `POST /auction` and publisher SSAT/page-bids delivery while skipping first-party resource/click URL rewriting; it also skips creative TSJS injection on `POST /auction`.
- Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `<base>` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery.
- Added Osano consent mirror integration docs and public enablement guidance.
- Implemented basic authentication for configurable endpoint paths (#73)
- Added integrations guide with example `testlight` integration
Expand Down
6 changes: 5 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ fn named_routes() -> [NamedRoute; 13] {
},
NamedRoute {
path: "/first-party/proxy-rebuild",
primary_methods: &[Method::POST],
// GET serves the click guard's navigation fallback: the creative
// iframe is an opaque origin (sandbox without `allow-same-origin`),
// so its JSON POST is blocked by CORS and the guard navigates here
// for a 302 instead.
primary_methods: &[Method::GET, Method::POST],
Comment thread
aram356 marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 P1 — Axum cannot complete the new GET rebuild redirect chain

The rebuild handler now accepts an origin-form URI and returns a relative /first-party/click?... Location. The browser's second request is also origin-form under Axum, but handle_first_party_click passes req.uri().to_string() to url::Url::parse (proxy.rs:1537, 1964), which requires an absolute URL. The first hop therefore 302s and the second hop fails instead of reaching the advertiser.

Please make the shared signed-target parser accept both absolute-form and origin-form URIs, rather than fixing only the rebuild parser, and add an Axum integration test with a valid signed click that follows both redirects.

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 @@ -91,6 +91,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
10 changes: 10 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,16 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
handle_first_party_proxy_sign(&s.settings, &services, req).await
}),
)
// GET serves the click guard's navigation fallback: the creative
// iframe is an opaque origin (sandbox without `allow-same-origin`),
// so its JSON POST is blocked by CORS and the guard navigates here
// for a 302 instead.
.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 @@ -230,6 +230,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: 5 additions & 1 deletion crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +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`] |
//! | GET | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] |
//! | 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 |
Expand Down Expand Up @@ -1113,7 +1114,10 @@ const NAMED_ROUTES: &[NamedRoute] = &[
},
NamedRoute {
path: "/first-party/proxy-rebuild",
primary_methods: &[Method::POST],
// GET serves the click guard's navigation fallback: the creative iframe
// is an opaque origin (sandbox without `allow-same-origin`), so its JSON
// POST is blocked by CORS and the guard navigates here for a 302 instead.
primary_methods: &[Method::GET, Method::POST],
handler: NamedRouteHandler::FirstPartyProxyRebuild,
},
];
Expand Down
11 changes: 8 additions & 3 deletions crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] {
("/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 @@ -610,7 +610,10 @@ 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 — GET serves the click guard's
// navigation fallback: the creative iframe is an opaque origin (sandbox
// without `allow-same-origin`), so its JSON POST is blocked by CORS and
// the guard navigates here for a 302 instead.
let s = Arc::clone(&state);
let fp_rebuild_handler = move |ctx: RequestContext| {
let s = Arc::clone(&s);
Expand All @@ -624,6 +627,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 @@ -749,7 +753,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
19 changes: 19 additions & 0 deletions crates/trusted-server-adapter-spin/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,25 @@ 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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — The comment promises more than the assertion checks

the route must be registered for GET (the handler 302s)

The test only asserts status != 404, so a 400 or 500 passes — which is the exact weakness called out on the axum registration last round. The real 302 assertion lives in proxy_rebuild_get_with_origin_form_uri_redirects, and routing is genuinely all this test can check cheaply. Either drop the parenthetical or assert 302 with a validly signed tsclick as the core test does.

// The opaque-origin creative click guard recovers via GET navigation, so the
// route must be registered for GET (the handler 302s) and must not fall
// through to the publisher origin.
let router = test_router();
let req = request_builder()
.method("GET")
.uri("/first-party/proxy-rebuild?tsclick=%2Ffirst-party%2Fclick%3Ftsurl%3Dhttps%253A%252F%252Fexample.com")
.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
48 changes: 48 additions & 0 deletions crates/trusted-server-cli/tests/config_env_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ ids = ["trusted_server_config"]
ids = ["trusted_server_secrets"]
"#;
const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES";
const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES";

struct MigratedProject {
directory: TempDir,
Expand All @@ -42,7 +43,11 @@ fn migrated_legacy_project() -> MigratedProject {
let mut document = LEGACY_CONFIG
.parse::<DocumentMut>()
.expect("should parse legacy integration config");
// EdgeZero v0.0.4 environment overlays cannot create missing TOML leaves,
// so a migrated config must carry both creative-processing leaves for the
// corresponding environment variables to take effect.
document["auction"]["rewrite_creatives"] = value(true);
document["auction"]["sanitize_creatives"] = value(false);
fs::write(&config_path, document.to_string()).expect("should write migrated config");
fs::write(&manifest_path, MANIFEST).expect("should write test manifest");
MigratedProject {
Expand Down Expand Up @@ -107,6 +112,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() {
);
}

#[test]
fn migrated_legacy_config_applies_sanitize_creatives_environment_override() {
let project = migrated_legacy_project();
let output = Command::new(env!("CARGO_BIN_EXE_ts"))
.args(["config", "push", "--adapter", "axum", "--manifest"])
.arg(&project.manifest_path)
.arg("--app-config")
.arg(&project.config_path)
.args(["--yes", "--no-diff"])
.current_dir(project.directory.path())
.env(SANITIZE_ENV, "true")
.output()
.expect("should run ts config push");

assert!(
output.status.success(),
"valid boolean overlay should push successfully: {}",
String::from_utf8_lossy(&output.stderr)
);

let local_store_path = project
.directory
.path()
.join(".edgezero/local-config-trusted_server_config.json");
let local_store: serde_json::Value = serde_json::from_str(
&fs::read_to_string(local_store_path).expect("should read pushed local config"),
)
.expect("should parse local config store");
let envelope_json = local_store
.as_object()
.and_then(|entries| entries.values().next())
.and_then(serde_json::Value::as_str)
.expect("should contain a blob envelope");
let envelope: serde_json::Value =
serde_json::from_str(envelope_json).expect("should parse blob envelope");

assert_eq!(
envelope["data"]["auction"]["sanitize_creatives"],
serde_json::Value::Bool(true),
"pushed config should contain the sanitize environment override"
);
}

#[test]
fn migrated_legacy_config_default_rewrite_creatives_has_no_local_diff() {
let project = migrated_legacy_project();
Expand Down
22 changes: 12 additions & 10 deletions crates/trusted-server-core/src/auction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ When a request arrives at the `/auction` endpoint, it goes through the following
┌──────────────────────────────────────────────────────────────────────┐
│ 11. Transform to OpenRTB Response (mod.rs:274-322) │
│ - Build seatbid array (one per winning bid) │
│ - Always sanitize creative HTML
│ - Sanitize creative HTML when enabled (opt-in)
│ - Rewrite creative HTML when enabled (default) │
│ - Add orchestrator metadata (timing, strategy, bid count) │
└──────────────────────────────────────────────────────────────────────┘
Expand Down Expand Up @@ -249,12 +249,15 @@ The orchestrator collects all bids and creates an OpenRTB response:
}
```

Creative HTML is always sanitized. By default, each auction delivery path then
rewrites eligible URLs through the first-party proxy (`/first-party/proxy`). The
`POST /auction` response also injects the creative runtime; the publisher SSAT
inline path uses absolute first-party URLs without injecting that bundle. Setting
`[auction].rewrite_creatives = false` skips rewriting in both paths and runtime
injection on `POST /auction`.
With `[auction].sanitize_creatives = true` (opt-in, default `false`),
executable markup is stripped with its inner content before delivery. With
`[auction].rewrite_creatives = true` (the default), each auction delivery path
rewrites eligible URLs through the first-party proxy (`/first-party/proxy`) and
removes bidder `<base>` elements. The `POST /auction` response also injects the
creative runtime; the publisher SSAT inline path uses absolute first-party URLs
without injecting that bundle. With both disabled, the creative ships exactly
as the bidder returned it. In every mode, creatives over the 1 MiB cap are
rejected.

## Route Registration & Endpoints

Expand All @@ -268,7 +271,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()` | Re-sign mutated click URLs (GET 302s for the opaque-origin click guard) | 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 Expand Up @@ -385,8 +388,7 @@ The `/auction` endpoint is the primary entry point for auctions:
**Key Transformations:**
- `adUnits[].code` → `seatbid[].bid[].impid` (slot identifier)
- `mediaTypes.banner.sizes` → evaluated by providers, winning size in `bid.w` and `bid.h`
- Creative HTML is always sanitized, then rewritten to use `/first-party/proxy` URLs by default
- `[auction].rewrite_creatives = false` skips rewriting in both delivery paths and `POST /auction` creative runtime injection, not sanitization
- Creative HTML: `[auction].sanitize_creatives = true` (opt-in) strips executable markup; `[auction].rewrite_creatives = true` (default) rewrites eligible URLs to `/first-party/proxy` in both delivery paths (with creative runtime injection on `POST /auction` only); with both disabled the creative ships as the bidder returned it
- Multiple bids per slot become separate `seatbid` entries
- Orchestrator metadata added in `ext.orchestrator`

Expand Down
Loading
Loading