Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- Prebid Server receives only allowlisted consent cookies in `both` and `cookies_only`, and no Cookie header in `openrtb_only` (#763). Cookie selection matches consent extraction, including last-duplicate selection and GPP section whitespace normalization. If an extracted consent cookie cannot be forwarded safely, `cookies_only` retains only the affected signal in the OpenRTB body; GPP and its section IDs fall back together. Publisher session and identity cookies are excluded in every mode. **Breaking** for deployments that relied on other cookies reaching PBS: Prebid Server's auction endpoint reads its `uids` user-sync cookie and configured host cookie from the request, so when that cookie domain covers the publisher site, those buyer UIDs no longer arrive through Trusted Server.
- Didomi SDK requests now derive `X-Geo-Country`, `X-Geo-Region`, and `CloudFront-Viewer-Country` only from trusted platform geo (#1151). Caller-supplied `FastlyGeo-CountryCode` and `FastlyGeo-Region` headers previously let callers choose the geography sent to Didomi. **Breaking** for deployments that injected those headers: configure CDN-fronted client IP instead. Without a complete trusted country/region pair (currently always on Cloudflare, Axum, and Spin), the geo header group is omitted. Noncanonical loaders and SDK POSTs bypass the outbound cache and return `no-store, private`; other SDK GETs retain Didomi's cache policy. Purge cached SDK entries before rollout. Geo-less loaders risk Didomi throttling and disablement; production Fastly deployments should validate geo coverage and enable `geo_query_parameters = true`. Other adapters currently have no compliant loader-caching mode through this proxy; use direct SDK delivery instead. Audit-generated configurations leave detected Didomi disabled for manual review.
- `ts prebid client` now keeps the config file's mode bits when it writes `external_bundle_sha256` and `external_bundle_sri`; previously a `0600` config was reset to the umask default (usually `0644`). Configs already rewritten by an earlier version keep the widened mode, so restrict them again (for example `chmod 600 trusted-server.toml`). The rewrite is fsynced and leaves no temporary copy behind on failure. TOML parse errors from `ts prebid client` and `ts audit generate` now name the file, line and column without quoting the offending config line (#1202).
- Reserve the application-visible `/_ts/trace*` prefix locally on every adapter, including when mobile tracing is disabled. Existing authentication runs first; disabled or unknown trace routes then return a local `404` instead of forwarding to the publisher.
Expand Down
14 changes: 7 additions & 7 deletions crates/trusted-server-core/src/auction/openrtb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use super::routing::{
PrebidTransportHeaders, ProviderAuctionInput, ProviderSlotInput, RoutedAuction,
};
use super::types::{AdFormat, AdSlot, AuctionResponse, Bid};
use crate::consent::ConsentSource;
use crate::error::TrustedServerError;
use crate::openrtb::{
Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs,
Expand Down Expand Up @@ -347,13 +346,14 @@ fn build_common_request(
ProfilePolicy::Standard(_) => common.publisher.page_url.clone(),
};
let consent = common.user.consent.as_ref();
let body_consent = match policy {
ProfilePolicy::Prebid(plan) => consent.filter(|value| {
plan.consent_forwarding.includes_body_consent()
|| !matches!(value.source, ConsentSource::Cookie)
}),
_ => consent,
let selected_consent = match policy {
ProfilePolicy::Prebid(plan) => routed
.prebid_transport_headers()
.consent_cookies()
.body_consent(consent, plan.consent_forwarding),
_ => consent.map(std::borrow::Cow::Borrowed),
};
let body_consent = selected_consent.as_deref();
let raw_tc = body_consent.and_then(|value| value.raw_tc_string.clone());
let user = Some(User {
id: common.user.id.clone(),
Expand Down
343 changes: 338 additions & 5 deletions crates/trusted-server-core/src/auction/orchestrator.rs

Large diffs are not rendered by default.

34 changes: 23 additions & 11 deletions crates/trusted-server-core/src/auction/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use serde_json::Value;

use super::plan::{AuctionPlan, BidderId, ProviderId, RoutingMode};
use super::types::{AdSlot, AuctionRequest, MediaType};
use crate::integrations::prebid::PrebidConsentCookies;

const TRUSTED_SERVER_ENVELOPE: &str = "trustedServer";
const BIDDER_PARAMS_FIELD: &str = "bidderParams";
Expand Down Expand Up @@ -199,19 +200,21 @@ impl ProviderSlotInput {

/// Request headers approved for later Prebid transport forwarding.
///
/// Values remain as raw [`HeaderValue`] instances so non-ASCII bytes retain
/// the same legacy handling. Client-supplied `X-Forwarded-For` is never read.
/// The inbound `Cookie` header is reduced to [`PrebidConsentCookies`] when the
/// snapshot is taken, so publisher session and identity cookies are never
/// retained for the auction. Other captured headers retain their bytes as
/// [`HeaderValue`] instances. Client-supplied `X-Forwarded-For` is never read.
#[derive(Debug, Clone, Default)]
pub(crate) struct PrebidTransportHeaders {
cookie: Option<HeaderValue>,
consent_cookies: PrebidConsentCookies,
user_agent: Option<HeaderValue>,
referer: Option<HeaderValue>,
accept_language: Option<HeaderValue>,
}

impl PrebidTransportHeaders {
pub(crate) fn cookie(&self) -> Option<&HeaderValue> {
self.cookie.as_ref()
pub(crate) fn consent_cookies(&self) -> &PrebidConsentCookies {
&self.consent_cookies
}

pub(crate) fn user_agent(&self) -> Option<&HeaderValue> {
Expand All @@ -228,7 +231,9 @@ impl PrebidTransportHeaders {

fn snapshot(request: &Request<EdgeBody>) -> Self {
Self {
cookie: request.headers().get(header::COOKIE).cloned(),
consent_cookies: PrebidConsentCookies::from_header(
request.headers().get(header::COOKIE),
),
user_agent: request.headers().get(header::USER_AGENT).cloned(),
referer: request.headers().get(header::REFERER).cloned(),
accept_language: request.headers().get(header::ACCEPT_LANGUAGE).cloned(),
Expand Down Expand Up @@ -1303,12 +1308,15 @@ mod tests {
#[test]
fn snapshots_first_headers_retains_raw_bytes_and_ignores_inbound_xff() {
let mut inbound = inbound();
inbound.headers_mut().append(
header::COOKIE,
HeaderValue::from_static(
"session_id=example-session; ts-ec=example-ec; euconsent-v2=TC",
),
);
inbound
.headers_mut()
.append(header::COOKIE, HeaderValue::from_static("first=1"));
inbound
.headers_mut()
.append(header::COOKIE, HeaderValue::from_static("second=2"));
.append(header::COOKIE, HeaderValue::from_static("us_privacy=1YNN"));
inbound.headers_mut().append(
header::USER_AGENT,
HeaderValue::from_bytes(b"agent-\x80").expect("should accept raw header"),
Expand Down Expand Up @@ -1336,7 +1344,11 @@ mod tests {
Some(attested),
);
let headers = routed.prebid_transport_headers();
assert_eq!(headers.cookie(), Some(&HeaderValue::from_static("first=1")));
assert_eq!(
headers.consent_cookies().header(),
Some(&HeaderValue::from_static("euconsent-v2=TC")),
"should retain only consent cookies from the first Cookie header"
);
assert_eq!(
headers.user_agent().expect("should retain UA").as_bytes(),
b"agent-\x80"
Expand Down
37 changes: 6 additions & 31 deletions crates/trusted-server-core/src/consent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,30 +125,23 @@ pub enum ConsentMode {

/// How consent signals are forwarded to a specific partner integration.
///
/// Controls whether consent travels through the `OpenRTB` body, raw `Cookie`
/// headers, or both. The default (`Both`) preserves backward compatibility.
/// Controls whether consent travels through the `OpenRTB` body, consent
/// cookies, or both. PBS permits only allowlisted consent cookies and omits
/// the entire `Cookie` header in [`Self::OpenrtbOnly`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConsentForwardingMode {
/// Forward consent in the `OpenRTB` body only; strip consent cookies.
/// Forward consent in the `OpenRTB` body only; PBS sends no `Cookie` header.
Comment thread
prk-Jr marked this conversation as resolved.
Resolved
OpenrtbOnly,
/// Forward consent cookies only; omit consent fields from the body.
/// Prefer consent cookies; omit cookie-sourced body consent when cookies
/// can carry it safely. Policy-sourced signals retain a body fallback.
Comment thread
prk-Jr marked this conversation as resolved.
Resolved
CookiesOnly,
/// Forward consent in both cookies and body (default).
#[default]
Both,
}

impl ConsentForwardingMode {
/// Whether consent cookies should be stripped from forwarded requests.
///
/// Returns `true` for [`OpenrtbOnly`](Self::OpenrtbOnly) since consent
/// travels exclusively through the request body in that mode.
#[must_use]
pub const fn strips_consent_cookies(self) -> bool {
matches!(self, Self::OpenrtbOnly)
}

/// Whether consent fields should be included in the request body.
///
/// Returns `true` for [`OpenrtbOnly`](Self::OpenrtbOnly) and
Expand Down Expand Up @@ -421,24 +414,6 @@ mod tests {
assert_eq!(config.mode, ConsentMode::Proxy, "should parse proxy mode");
}

#[test]
fn consent_forwarding_mode_strips_cookies_only_for_openrtb() {
use super::ConsentForwardingMode;

assert!(
ConsentForwardingMode::OpenrtbOnly.strips_consent_cookies(),
"openrtb_only should strip consent cookies"
);
assert!(
!ConsentForwardingMode::CookiesOnly.strips_consent_cookies(),
"cookies_only should not strip consent cookies"
);
assert!(
!ConsentForwardingMode::Both.strips_consent_cookies(),
"both should not strip consent cookies"
);
}

#[test]
fn consent_forwarding_mode_includes_body_consent_except_cookies_only() {
use super::ConsentForwardingMode;
Expand Down
Loading
Loading