From f2086639fb7a222f1b0adac989bb275f0704c213 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 17:29:13 +0100 Subject: [PATCH 1/4] The Model Terms cookie name is declared by the module that reads it `COOKIE_MTM_PREF` sat in core's constants, and the Model Terms for Marketing signal crate is the only code that reads the cookie. The constant moves to that crate, so core names one standard less. The Axum adapter's test of the signal takes the name from the crate. --- crates/permission-signal/mtm/src/lib.rs | 8 +++++++- .../tests/permission_signals.rs | 2 +- crates/trusted-server-core/src/constants.rs | 7 ------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs index f1692667e4..7e795f2255 100644 --- a/crates/permission-signal/mtm/src/lib.rs +++ b/crates/permission-signal/mtm/src/lib.rs @@ -39,7 +39,6 @@ use std::sync::OnceLock; -use trusted_server_core::constants::COOKIE_MTM_PREF; use trusted_server_core::evidence::RequestInfo; use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; @@ -67,6 +66,13 @@ fn short() -> &'static str { /// The scheme, as a [`ValidSignal`] names it. pub const SCHEME: &str = "mtm"; +/// The cookie holding the preference, one of the three words `standard`, +/// `personalized` and `non-marketing`. It is written as a first party cookie +/// by whatever preference platform asked the visitor, and is not a Trusted +/// Server name, so that anyone can build a platform that sets it. This module +/// reads the cookie and never writes it. +pub const COOKIE_MTM_PREF: &str = "__mtm_pref"; + /// The terms the data is available under when a word is present. Versioned, /// and never a page that can be edited, because whoever receives the data /// has to be able to prove what terms were in force when it was sent. diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 5dec34de63..8d77cc204c 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -611,7 +611,7 @@ fn a_visitor_in_the_eu_with_no_record_is_awaiting_what_tcf_could_grant() { #[test] fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() { - use trusted_server_core::constants::COOKIE_MTM_PREF; + use trusted_server_permission_signal_mtm::COOKIE_MTM_PREF; let geo = GeoInfo { city: String::new(), diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index d9e47c59e3..bbb1f19f65 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -13,13 +13,6 @@ pub const COOKIE_TS_EC_PULL_COMPLETE: &str = "ts-ec-pull-complete"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; -/// The Model Terms for Marketing preference, one of the three words -/// `standard`, `personalized` and `non-marketing`, written as a first party -/// cookie by whatever preference platform asked the visitor and read by the -/// Model Terms for Marketing signal module. Not a Trusted Server name, so -/// that anyone can build a platform that sets it. Trusted Server reads this -/// cookie and never writes it. -pub const COOKIE_MTM_PREF: &str = "__mtm_pref"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); From f8d0ccfe3fd1ca15ae4f56903575a2d9db96b6cd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 08:15:24 +0100 Subject: [PATCH 2/4] Core names no Model Terms cookie, and the module reads one of three words The README of core's permission signal module named the cookie, though core holds no scheme's wire format. It now says the module declares the name. The module's comment on valid_signal and the mtm row of the permission signals guide said two words where the module reads three. --- crates/permission-signal/mtm/src/lib.rs | 2 +- .../src/permission_signal/README.md | 4 ++-- docs/guide/permission-signals.md | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs index 7e795f2255..aa5749c611 100644 --- a/crates/permission-signal/mtm/src/lib.rs +++ b/crates/permission-signal/mtm/src/lib.rs @@ -237,7 +237,7 @@ impl PermissionSignalModule for MtmModule { } /// The word, when one is present. There is nothing else to validate, - /// because a value that is not one of the two words is not an answer. + /// because a value that is not one of the three words is not an answer. fn valid_signal(&self, call: ModuleCall<'_>) -> Option { call.inject(self, Self::read_signal).ok().flatten() } diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md index 7c2c8d25aa..999a71964e 100644 --- a/crates/trusted-server-core/src/permission_signal/README.md +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -36,8 +36,8 @@ reads its own signal from the request, without a change to core. The fifth, Model Terms for Marketing (MTM), is the first terms scheme, where a publisher and the parties it passes data to agree to be bound by a published set of terms, and what the module reads is the visitor's answer to a preference -platform, one of three words in the first party cookie -`__mtm_pref`, a name any platform may set. It is one of many terms schemes +platform, one of three words in a first party cookie whose name the module +declares and any platform may set. It is one of many terms schemes rather than the only one, because a publisher, a trade body or a regulator can each publish terms and each set becomes a module. The five here are a starting set and not the list. diff --git a/docs/guide/permission-signals.md b/docs/guide/permission-signals.md index bb9dba4362..aec37e1ba6 100644 --- a/docs/guide/permission-signals.md +++ b/docs/guide/permission-signals.md @@ -114,13 +114,13 @@ question about a jurisdiction and a publisher. ## The modules that ship -| Name | Crate | Reads | -| ------------ | ------------------------------------- | --------------------------------------------------------------------- | -| `gpc` | `crates/permission-signal/gpc` | The `Sec-GPC` header, Global Privacy Control | -| `gpp` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | -| `us-privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | -| `tcf` | `crates/permission-signal/tcf` | A TCF v2 record, with the purpose mapping in code | -| `mtm` | `crates/permission-signal/mtm` | The PMP answer, one of two words, under the Model Terms for Marketing | +| Name | Crate | Reads | +| ------------ | ------------------------------------- | ----------------------------------------------------------------------- | +| `gpc` | `crates/permission-signal/gpc` | The `Sec-GPC` header, Global Privacy Control | +| `gpp` | `crates/permission-signal/gpp` | The US sale opt-out carried in a GPP string | +| `us-privacy` | `crates/permission-signal/us-privacy` | The sale opt-out in a US Privacy string | +| `tcf` | `crates/permission-signal/tcf` | A TCF v2 record, with the purpose mapping in code | +| `mtm` | `crates/permission-signal/mtm` | The PMP answer, one of three words, under the Model Terms for Marketing | The three opt-outs are separate so that a publisher who does not act on Global Privacy Control can remove it and keep the other two. What an opt-out takes From 3899034052ba37998bd96c49c5de02074daec2ab Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:50:14 +0100 Subject: [PATCH 3/4] Prebid Server receives the Model Terms preference only where it receives the consent cookies With consent_forwarding set to openrtb_only, the four consent cookies were removed from the Cookie header sent to Prebid Server, and __mtm_pref was not, so the visitor's preference reached Prebid Server in every mode. It is now removed with them, using the name the Model Terms crate declares, so core still names no scheme's cookie. One test per mode drives the dispatch path. --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/auction/prebid-server/Cargo.toml | 1 + crates/auction/prebid-server/src/lib.rs | 85 +++++++++++++++++++++++-- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3613db93a..608cb527bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- With `consent_forwarding = "openrtb_only"`, the Model Terms for Marketing preference cookie, `__mtm_pref`, is withheld from Prebid Server along with the consent cookies. It was forwarded in every mode. - A response no longer carries `X-Served-By`, `X-Cache` or `X-Cache-Hits`, unless this server answered a request for a path beneath `/_ts`. The three name the node that answered and say how a cache treated the request, and arrive from the publisher's origin or a cache on the way to it. Every adapter removes them before it finalizes a response, so one set in `[response_headers]` is still sent. - No adapter writes a version on a response. What the build was made from is read from the attestation document, under `build`, and the platform's version number of the service under `platform.serviceVersion`, and a deployment that wants them on its pages names core's `service_version` middleware in a `[[serve]]` entry. - `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect. diff --git a/Cargo.lock b/Cargo.lock index 66e990e61f..629f061b9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6720,6 +6720,7 @@ dependencies = [ "serde_json", "tokio", "trusted-server-core", + "trusted-server-permission-signal-mtm", "url", "validator", "web-time", diff --git a/crates/auction/prebid-server/Cargo.toml b/crates/auction/prebid-server/Cargo.toml index c1bfa1cefd..9932472017 100644 --- a/crates/auction/prebid-server/Cargo.toml +++ b/crates/auction/prebid-server/Cargo.toml @@ -21,6 +21,7 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-mtm = { workspace = true } url = { workspace = true } [dev-dependencies] diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index 35fd0b3e6d..dfc5f153f1 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -81,6 +81,7 @@ use trusted_server_core::platform::RuntimeServices; use trusted_server_core::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; #[cfg(test)] use trusted_server_core::settings::{IntegrationConfig, Settings}; +use trusted_server_permission_signal_mtm::COOKIE_MTM_PREF; /// The name an `implementation` line gives this implementation, its module /// path, which also serves as its builder's id. @@ -1064,9 +1065,9 @@ fn non_empty_override_object( /// synthesized from `client_ip` — the platform-attested client address — /// matching the trusted IP already sent in `OpenRTB` `device.ip`. /// -/// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are -/// stripped from the `Cookie` header since consent travels exclusively -/// through the `OpenRTB` body. +/// In [`ConsentForwardingMode::OpenrtbOnly`] mode, the consent cookies and +/// the Model Terms for Marketing preference are stripped from the `Cookie` +/// header since consent travels exclusively through the `OpenRTB` body. #[cfg(test)] fn copy_request_headers( from: &http::Request, @@ -1142,7 +1143,12 @@ fn apply_prebid_header_values( } match cookie_value.to_str() { Ok(value) => { - let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); + // The Model Terms for Marketing preference records the visitor's + // choice as the consent cookies do, so it is withheld with them. + let stripped = strip_cookies( + &strip_cookies(value, CONSENT_COOKIE_NAMES), + &[COOKIE_MTM_PREF], + ); if !stripped.is_empty() && let Ok(cookie_header) = HeaderValue::from_str(&stripped) { @@ -7188,6 +7194,77 @@ set = { networkId = 42 } } } + /// A page request from a visitor who has answered a consent prompt and + /// the Model Terms for Marketing preference question. + const ANSWERED: &str = "session=fictional; euconsent-v2=fictional-tcf; __mtm_pref=standard"; + + /// The `Cookie` header Prebid Server receives in `mode` for a page request + /// carrying `cookie`, through the dispatch path. + fn cookie_sent_to_prebid_server( + mode: ConsentForwardingMode, + cookie: &'static str, + ) -> Option> { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(204, Vec::new()); + let services = build_services_with_http_client_and_client_ip( + Arc::clone(&stub) as Arc, + std::net::IpAddr::from([203, 0, 113, 9]), + ); + let settings = make_settings(); + let mut config = base_config(); + config.consent_forwarding = mode; + let provider = PrebidAuctionProvider::new(config); + let inbound = http::Request::builder() + .uri("https://pub.example/auction") + .header(header::COOKIE, cookie) + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + + futures::executor::block_on( + provider.request_bids(&create_test_auction_request(), &context), + ) + .expect("should dispatch PBS request"); + + let headers = stub.recorded_request_header_bytes(); + assert_eq!(headers.len(), 1, "should dispatch one request"); + header_value(&headers[0], "cookie").map(<[u8]>::to_vec) + } + + #[test] + fn openrtb_only_withholds_the_model_terms_preference_from_prebid_server() { + assert_eq!( + cookie_sent_to_prebid_server(ConsentForwardingMode::OpenrtbOnly, ANSWERED).as_deref(), + Some(b"session=fictional".as_slice()), + "should withhold the preference wherever the consent cookies are withheld" + ); + } + + #[test] + fn cookies_only_forwards_the_model_terms_preference_to_prebid_server() { + assert_eq!( + cookie_sent_to_prebid_server(ConsentForwardingMode::CookiesOnly, ANSWERED).as_deref(), + Some(ANSWERED.as_bytes()), + "should forward the preference with the consent cookies" + ); + } + + #[test] + fn both_forwards_the_model_terms_preference_to_prebid_server() { + assert_eq!( + cookie_sent_to_prebid_server(ConsentForwardingMode::Both, ANSWERED).as_deref(), + Some(ANSWERED.as_bytes()), + "should forward the preference with the consent cookies" + ); + } + #[test] fn pbs_request_serialization_goldens_cover_disabled_and_deterministic_enabled_signing() { let provider = PrebidAuctionProvider::new(base_config()); From 71e039d4c8bf17c3a9211d7f550e505e1a0d5082 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:50:17 +0100 Subject: [PATCH 4/4] A test checks the cookie name against the platform's own Set-Cookie strings The module's tests built the cookie from COOKIE_MTM_PREF, and the fixtures of the platform's real output were read for their value only, so a changed name passed every test while no visitor's answer would be read. --- crates/permission-signal/mtm/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs index aa5749c611..d35476f33d 100644 --- a/crates/permission-signal/mtm/src/lib.rs +++ b/crates/permission-signal/mtm/src/lib.rs @@ -336,6 +336,21 @@ mod tests { } } + /// Every string the platform writes names the cookie this module reads, so + /// a change to either name fails here rather than leaving every visitor's + /// answer unread. + #[test] + fn the_platforms_own_set_cookie_strings_name_the_cookie_the_module_reads() { + for (_, set_cookie) in PMP_SET_COOKIE { + let pair = set_cookie.split(';').next().unwrap_or_default().trim(); + let (name, _) = pair.split_once('=').expect("a cookie is name=value"); + assert_eq!( + name, COOKIE_MTM_PREF, + "the module must read the name the platform writes: {set_cookie}", + ); + } + } + /// The cookie has to stay readable by the script that owns the question. /// /// A browser lets a script neither read nor replace a cookie carrying