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
56 changes: 56 additions & 0 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,11 @@ pub struct PrebidIntegrationConfig {
pub timeout_ms: u32,
#[serde(default)]
pub debug: bool,
/// Opt into the relay's basic-ads soft vendor exception for testing.
///
/// Defaults to false. Purpose consent and publisher GVL mappings remain enforced.
#[serde(default)]
pub relay_soft_vendor_exception: bool,
#[serde(
default = "default_script_patterns",
deserialize_with = "crate::settings::vec_from_seq_or_map"
Expand Down Expand Up @@ -574,6 +579,7 @@ impl Default for PrebidIntegrationConfig {
account_id: None,
timeout_ms: default_timeout_ms(),
debug: false,
relay_soft_vendor_exception: false,
script_patterns: default_script_patterns(),
external_bundle_url: None,
external_bundle_sha256: None,
Expand All @@ -600,6 +606,7 @@ impl From<&LegacyPrebidServerConfig> for PrebidIntegrationConfig {
account_id: config.account_id.clone(),
timeout_ms: config.timeout_ms,
debug: config.debug,
relay_soft_vendor_exception: false,
script_patterns: config.script_patterns.clone(),
external_bundle_url: config.external_bundle_url.clone(),
external_bundle_sha256: config.external_bundle_sha256.clone(),
Expand Down Expand Up @@ -1576,6 +1583,7 @@ struct InjectedPrebidClientConfig<'a> {
managed_user_ids: Vec<InjectedManagedUserId<'a>>,
timeout: u32,
debug: bool,
relay_soft_vendor_exception: bool,
#[serde(skip_serializing_if = "Option::is_none")]
bidders: Option<&'a [String]>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -1597,6 +1605,7 @@ impl<'a> From<&'a PrebidIntegrationConfig> for InjectedPrebidClientConfig<'a> {
.collect(),
timeout: config.timeout_ms,
debug: config.debug,
relay_soft_vendor_exception: config.relay_soft_vendor_exception,
bidders: None,
server_side_bidders: None,
client_side_bidders: &config.client_side_bidders,
Expand Down Expand Up @@ -5474,6 +5483,53 @@ external_bundle_sri = "sha384-AAAA"
);
}

#[test]
fn planned_registration_injects_relay_soft_vendor_exception() {
assert!(
!PrebidIntegrationConfig::default().relay_soft_vendor_exception,
"should default the relay exception to false"
);
for configured in [None, Some(false), Some(true)] {
let mut settings = make_settings();
let mut config = json!({
"enabled": true,
"external_bundle_url": "https://assets.example/prebid/trusted-prebid.js"
});
if let Some(value) = configured {
config["relay_soft_vendor_exception"] = json!(value);
}
settings
.integrations
.insert_config("prebid", &config)
.expect("should configure prebid");
let plan = crate::auction::compile_auction_plan(&settings)
.expect("should compile auction plan");
let document_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &document_state,
};
for enabled in [true, false] {
let registration =
register_for_plan(&settings, &plan.clone().with_enabled(enabled))
.expect("should register prebid")
.expect("should enable prebid");
let inserts = registration.head_injectors[0].head_inserts(&ctx);
let expected = format!(
r#""relaySoftVendorException":{}"#,
configured.unwrap_or(false)
);
assert!(
inserts[0].contains(&expected),
"should inject the configured relay exception for {configured:?}: {}",
inserts[0]
);
}
}
}

#[test]
fn planned_registration_injects_managed_user_ids() {
let mut settings = make_settings();
Expand Down
11 changes: 11 additions & 0 deletions crates/trusted-server-js/lib/src/integrations/prebid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
userIdConfigNameAliases,
userIdSubmoduleKey,
} from './user_id_modules';
import { createRelayConsentGuard } from './relay_consent';

/**
* Prebid.js public API surface (type-only; erased at build time).
Expand Down Expand Up @@ -187,6 +188,8 @@ interface InjectedPrebidConfig {
accountId?: string;
timeout?: number;
debug?: boolean;
/** Opt-in relay basic-ads soft vendor exception for testing. Defaults to false. */
relaySoftVendorException?: boolean;
/** Validated browser bidder route codes owned by the server auction plan. */
serverSideBidders?: string[];
/** Bidders that run client-side via native Prebid.js adapters. */
Expand Down Expand Up @@ -2478,6 +2481,11 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
});

const originalRequestBids = pbjs.requestBids.bind(pbjs);
const prepareRelayConsent = createRelayConsentGuard(
ADAPTER_CODE,
(key) => (pbjs as unknown as { getConfig?: PrebidGetConfig }).getConfig?.(key),
(value) => pbjs.setConfig(value as PbjsConfig)

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.

🔧 wrench: Relay rule writes go through the managed-ID ownership wrapper

With managed_user_ids configured and a CMP on the page, the shim activates IAB consent itself and replaces pbjs.setConfig with a wrapper that treats any consentManagement.gdpr write as a publisher takeover. This setter resolves to that wrapper at call time, so the first non-empty auction with relay_soft_vendor_exception = true runs this sequence:

  1. retireAutomaticTcfConsent reads the relay rules as publisher ownership. It sets acceptCmpEvents = false and writes { gdpr: { enabled: false } }.
  2. The rules write turns cmpApi: 'iab' back on. Prebid 10.26 setConsentConfig calls loadConsentData() synchronously, so Prebid immediately opens a new __tcfapi addEventListener subscription on the unguarded API.
  3. A later publisher setConfig with a static GDPR denial finds no automatic owner left to retire. The next CMP event on the unguarded subscription overwrites the publisher's denial.

Repro on f22a83f: apply the three relay cases from the #1545 port of prebid-consent-enforcement.test.mjs unchanged (git diff <this head> <#1545 head> -- crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs | git apply). 4 of the 5 runs fail. After the relay auction and a static Purpose 1 / vendor 97 denial, IdentityLink still requests the LiveRamp envelope and writes idl_env. The checkpoint log shows the writes {"gdpr":{"enabled":false}} then {"gdpr":{"cmpApi":"iab","rules":[...]}}, with automatic listener 2 replaced by unguarded listener 3. Your comment on this PR notes that the correction is not on this branch; the PR description should say so too until it lands.

Scope: only relay_soft_vendor_exception = true together with managed_user_ids and a CMP. That is the testing setup this flag exists for.

Proposed fix (apply manually: it touches activateManagedUserIdTcfConsent outside this diff and adds tests in another file, so it can't be a one-click suggestion). Route internal relay writes through the automatic owner, and keep its __tcfapi guard installed while Prebid subscribes:

type ManagedTcfConsentActivation = {
  acceptCmpEvents: boolean;
  applyConfig: typeof pbjs.setConfig;
};

// activateManagedUserIdTcfConsent: replace the one-shot guarded setConfig.
const activation: ManagedTcfConsentActivation = {
  acceptCmpEvents: true,
  applyConfig: setConfig,
};
// ...
activation.applyConfig = (value) => {
  const previousTcfApi = tcfWindow.__tcfapi;
  tcfWindow.__tcfapi = guardedTcfApi;
  try {
    return setConfig(value);
  } finally {
    if (tcfWindow.__tcfapi === guardedTcfApi) tcfWindow.__tcfapi = previousTcfApi;
  }
};
activation.applyConfig({
  consentManagement: { ...effectiveConsent, gdpr: { cmpApi: 'iab' } },
} as PbjsConfig);

// installPrebidNpm, before the managed-ID block.
let applyRelayConsentConfig = (value: PbjsConfig) => pbjs.setConfig(value);

// Inside the managed-ID block, after `let automaticTcfConsentActivation`.
// Relay rule updates are internal writes, not a publisher ownership claim.
applyRelayConsentConfig = (value) => {
  const activeConsent = automaticTcfConsentActivation;
  return activeConsent?.acceptCmpEvents
    ? activeConsent.applyConfig(value)
    : originalSetConfig(value);
};

// Here.
(value) => applyRelayConsentConfig(value as PbjsConfig)

Then add the three #1545 relay regression tests to prebid-consent-enforcement.test.mjs. I applied this change to f22a83f in a scratch tree: all 5 relay cases pass, the full vitest run passes (81 files, 3,318 tests, no type errors), Prettier and ESLint are clean, and the shim is 53,628 characters, under the 54,000 guard.

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 — Relay updates can override a later publisher denial

With managed User IDs, a CMP, and relay_soft_vendor_exception = true, a nonempty auction makes this internal rules update go through the publisher ownership wrapper. It retires the automatic CMP guard, then Prebid synchronously creates an unguarded CMP subscription. A later CMP grant can overwrite the publisher's static denial.

I independently reproduced this with the built Prebid artifact and shim: after a static Purpose 1 denial and a subsequent CMP grant, the page made one stubbed identity request and wrote idl_env. The same probe with the flag disabled passed. This confirms the existing unresolved finding at this revision; the new static-consent relay matrix does not exercise the combined managed-ID ownership lifecycle.

Route internal updates through the managed consent owner and guard every resulting subscription, with combined lifecycle regression tests. Simply bypassing the publisher wrapper is insufficient if the replacement subscription loses its callback guard. Apply manually: the correction touches the existing activation/ownership code outside this diff and tests in another file, so it cannot be expressed as a one-click suggestion.

);

// Browser demand ownership is explicit. Only validated auction-plan route
// codes are folded; every other publisher bidder entry remains in Prebid.js.
Expand Down Expand Up @@ -2657,6 +2665,9 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
};

try {
if (adUnits.length > 0 && injected?.relaySoftVendorException === true) {
prepareRelayConsent();
}
return originalRequestBids(opts);
} catch (error) {
for (const [adUnitCode, token] of firstImpressionTokens) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { log } from '../../core/log';

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Creates a per-auction TCF guard for a first-party auction relay.
*
* The relay has no GVL identity of its own. A basic-ads soft vendor exception
* retains Prebid's purpose check while downstream auction endpoints receive
* the original consent signals for their vendor-specific enforcement.
*/
export function createRelayConsentGuard(
bidder: string,
getConfig: (key: string) => unknown,
setConfig: (config: { consentManagement: Record<string, unknown> }) => unknown
): () => void {
let addedException = false;
let applying = false;

return () => {
if (applying) return;
try {
const consent = getConfig('consentManagement');
if (!isRecord(consent)) return;
// tcfControl reads namespaced rules. Do not migrate publisher-owned
// legacy settings: later mergeConfig calls must retain their semantics.
const gdpr = consent.gdpr;
if (!isRecord(gdpr) || gdpr.enabled === false) return;
if (gdpr.rules !== undefined && !Array.isArray(gdpr.rules)) return;

const mapping = getConfig('gvlMapping');
const mapped = isRecord(mapping) && mapping[bidder] !== undefined;
// An operator-supplied vendor identity keeps its vendor-consent gate.
if (mapped && !addedException) return;

const rules: unknown[] = gdpr.rules ?? [];
let foundBasicAds = false;
let changed = false;
const nextRules = rules.map((rule) => {
if (!isRecord(rule) || rule.purpose !== 'basicAds') return rule;
foundBasicAds = true;
const exceptions = rule.softVendorExceptions ?? [];
if (!Array.isArray(exceptions)) return rule;
const containsRelay = exceptions.includes(bidder);
if (mapped ? !containsRelay : containsRelay) return rule;
changed = true;
return {
...rule,
softVendorExceptions: mapped
? exceptions.filter((entry) => entry !== bidder)
: [...exceptions, bidder],
};
});

if (!foundBasicAds && !mapped) {
// Custom rules replace Prebid's defaults, so retain both default gates.
nextRules.push({
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
softVendorExceptions: [bidder],
});
changed = true;
}
if (!changed) {
if (mapped) addedException = false;
return;
}

const nextGdpr = { ...gdpr, rules: nextRules };
applying = true;
addedException = !mapped;
setConfig({
consentManagement: { ...consent, gdpr: nextGdpr },
});
} catch (error) {
log.error('[tsjs-prebid] auction relay TCF configuration could not be prepared', error);
} finally {
applying = false;
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest';

import { createRelayConsentGuard } from '../../../src/integrations/prebid/relay_consent';

function setup(consent: Record<string, unknown>, mapping?: Record<string, unknown>) {
let currentConsent = consent;
let currentMapping = mapping;
const setConfig = vi.fn((value: { consentManagement: Record<string, unknown> }) => {
currentConsent = value.consentManagement;
});
const prepare = createRelayConsentGuard(
'trustedServer',
(key) => (key === 'consentManagement' ? currentConsent : currentMapping),
setConfig
);
return {
prepare,
setConfig,
consent: () => currentConsent,
mapVendor: () => {
currentMapping = { trustedServer: 1 };
},
replaceConsent: (value: Record<string, unknown>) => {
currentConsent = value;
},
};
}

describe('first-party relay TCF configuration', () => {
it('retains both default basic-ads gates and is idempotent', () => {
const { prepare, setConfig, consent } = setup({ gdpr: { cmpApi: 'iab' } });

prepare();
prepare();

expect(setConfig).toHaveBeenCalledTimes(1);
expect(consent()).toEqual({
gdpr: {
cmpApi: 'iab',
rules: [
{
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
softVendorExceptions: ['trustedServer'],
},
],
},
});
});

it('preserves publisher settings, other rules, and existing exceptions without mutation', () => {
const storage = { purpose: 'storage', enforcePurpose: true, enforceVendor: true };
const basicAds = {
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
vendorExceptions: ['exampleBidder'],
softVendorExceptions: ['anotherBidder'],
deferS2Sbidders: true,
};
const original = {
gdpr: { cmpApi: 'iab', timeout: 500, rules: [storage, basicAds] },
gpp: { cmpApi: 'iab' },
};
const { prepare, consent } = setup(original);

prepare();

expect(consent()).toEqual({
...original,
gdpr: {
...original.gdpr,
rules: [storage, { ...basicAds, softVendorExceptions: ['anotherBidder', 'trustedServer'] }],
},
});
expect(basicAds.softVendorExceptions).toEqual(['anotherBidder']);
expect(original.gdpr.rules).toEqual([storage, basicAds]);
});

it('leaves legacy TCF settings and their merge semantics unchanged', () => {
const original = {
cmpApi: 'static',
consentData: { gdprApplies: true },
strictStorageEnforcement: true,
};
const { prepare, consent, setConfig } = setup(original);

prepare();

expect(consent()).toEqual(original);
expect(setConfig).not.toHaveBeenCalled();
});

it.each([{}, { gpp: { cmpApi: 'iab' } }, { gdpr: { enabled: false } }])(
'does not activate GDPR collection for %j',
(original) => {
const { prepare, setConfig } = setup(original);

prepare();

expect(setConfig).not.toHaveBeenCalled();
}
);

it('honors an explicitly configured relay GVL identity', () => {
const { prepare, setConfig } = setup({ gdpr: { cmpApi: 'iab' } }, { trustedServer: 1 });

prepare();

expect(setConfig).not.toHaveBeenCalled();
});

it('prepares new publisher rules again before a later auction', () => {
const { prepare, replaceConsent, consent } = setup({ gdpr: { cmpApi: 'iab' } });
prepare();
replaceConsent({
strictStorageEnforcement: true,
gdpr: {
cmpApi: 'iab',
rules: [
{ purpose: 'basicAds', enforcePurpose: true, softVendorExceptions: ['exampleBidder'] },
],
},
});

prepare();

expect(consent()).toMatchObject({
strictStorageEnforcement: true,
gdpr: {
rules: [
{
purpose: 'basicAds',
enforcePurpose: true,
softVendorExceptions: ['exampleBidder', 'trustedServer'],
},
],
},
});
});

it('retires its own exception when a publisher configures a GVL identity later', () => {
const { prepare, mapVendor, consent } = setup({ gdpr: { cmpApi: 'iab' } });

prepare();
mapVendor();
prepare();

expect(consent()).toMatchObject({
gdpr: {
rules: [
{
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
softVendorExceptions: [],
},
],
},
});
});

it('leaves malformed publisher rules unchanged', () => {
const { prepare, setConfig } = setup({ gdpr: { cmpApi: 'iab', rules: 'invalid' } });

prepare();

expect(setConfig).not.toHaveBeenCalled();
});
});
Loading
Loading