diff --git a/CHANGELOG.md b/CHANGELOG.md index 5447933a50..636f094d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,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. A deployment that wants the versions on its pages names core's `service_version` middleware in a `[[serve]]` entry, which writes `X-TS-Version` from the git version the build compiled in and `X-TS-Service-Version` from the version the adapter reports. - A module's table that cannot be read or fails its validation is refused naming the setting at fault and never its value, because at startup the table holds the module's secrets resolved from the secret store and each adapter logs a refusal whole. diff --git a/Cargo.lock b/Cargo.lock index 5c043b7092..095db3e3fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6746,6 +6746,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 0a910c582b..581b457ae2 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) { @@ -7189,6 +7195,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()); diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs index e42d4617ee..464ddd82bc 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. @@ -231,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() } @@ -330,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 diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 0fb0457c1f..118aa891b4 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -612,7 +612,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 7d74790afa..1a2b12e4ca 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"); 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