Skip to content

Ingest /auction EIDs from the request body instead of the trimmed ts-eids cookie - #1188

Open
dhruv8sh wants to merge 3 commits into
mainfrom
fix/auction-eid-kv-truncation
Open

dhruv8sh wants to merge 3 commits into
mainfrom
fix/auction-eid-kv-truncation

Conversation

@dhruv8sh

Copy link
Copy Markdown
Collaborator

Summary

  • /auction's KV identity-graph writes only read the size-capped ts-eids cookie, silently dropping partners once the browser's 3072-char trim kicked in — even though the request body already carried the full EID set.
  • Thread /auction's parsed request-body EIDs through to KV ingestion so every registry-configured partner in the request lands in the identity graph, not just what fit in the cookie.
  • Also close a related gap found during review: the KV write path only checked TCF Purpose 1 (device storage) consent, not Purpose 4 (personalized ads) — now gated the same way the outbound bid request already is.

Changes

File Change
crates/trusted-server-core/src/ec/mod.rs New client_eids field + accessors on EcContext
crates/trusted-server-core/src/auction/endpoints.rs handle_auction hands its parsed body EIDs to ec_context
crates/trusted-server-core/src/ec/prebid_eids.rs collect_eid_cookie_updates renamed to collect_eid_updates; merges body EIDs alongside cookie EIDs
crates/trusted-server-core/src/ec/finalize.rs New collect_consent_gated_eid_updates gates the merged cookie + body update list on TCF Purpose 4 before any KV write
crates/trusted-server-js/lib/src/integrations/prebid/index.ts fitAuctionEidsToCookie now warns which sources it drops when the cookie still needs trimming
crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts Test for deterministic trimming + warning content

Closes

Closes #1184

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve — not run; fastly CLI isn't installed in this environment
  • Other: cargo clippy-cloudflare, cargo check-cloudflare (wasm32-unknown-unknown), cargo test-cloudflare

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses log macros (not println!) — note: template says "tracing", this repo uses log, not tracing
  • New code has tests
  • No secrets or credentials committed

…eids cookie

Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
@dhruv8sh
dhruv8sh requested a review from jevansnyc September 21, 2026 11:41

@prk-Jr prk-Jr left a comment

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.

Review: approve-with-nits

Reviewed at 94b8d18e8a6d3a2b17e831e8c8ba5d5784c25a52 in a scratch worktree. No CRITICAL or HIGH findings — the core change is sound and well-bounded, and the Purpose 4 gate is a real privacy fix beyond the stated scope of #1184.

Security question I went looking for: can a caller poison the identity graph? No.

The pre-existing KV write source was the ts-eids cookie, which TSJS writes from page script and is not HttpOnly — any same-origin script could already set it to arbitrary values. The /auction body is reachable by exactly the same actor, so this is not a new trust boundary.

A cross-site attacker cannot escalate: ts-ec is Secure; SameSite=Lax; HttpOnly (ec/cookies.rs:93), so a cross-origin fetch(..., {credentials:'include'}) POST carries no EC, ec_allowed() is false, ec_id is None, and client_eids is never set (auction/endpoints.rs:293-306). Poisoning stays confined to the caller's own EC row.

Write amplification: bounded at every layer

256 KiB body cap enforced twice (endpoints.rs:127-160); MAX_CLIENT_EID_SOURCES = 64 plus per-source UID and byte caps (endpoints.rs:491-537); the registry filter drops unconfigured sources so the update set is capped at registry.len(); dedupe_partner_updates collapses to one entry per partner; and apply_partner_id_updates returns false when every UID already matches, so the steady state costs zero KV writes.

Merge precedence matches the new doc comment

cookie -> body -> sharedId, then BTreeMap dedup = last-wins. Body beats a stale cookie, sharedId still wins its own source, and because finalize receives the raw cookie separately the union never loses a cookie-only partner. Correct shape.

One question before merge (see inline on finalize.rs)

Purpose 4 denial now empties updates, and an empty update list makes upsert_partner_ids_from_snapshot early-return without the load_snapshot refresh that orphan-EC recovery gates on. I believe that leaves a narrow segment (Purpose 1 granted / Purpose 4 denied, orphaned EC, non-GET publisher navigation) permanently unable to recover. Details inline — I'd like to know whether that coupling was intended.

Notes, not findings

  • Adapter parity: this whole path is Fastly-only — zero references to ec_finalize_response or KvIdentityGraph in the Cloudflare/Spin/Axum adapters. Pre-existing, and the same for the cookie ingestion this extends, so the parity suite can't regress on it. Flagging only so it's on the record.
  • Test quality vs AGENTS.md: passes. No unwrap(), every expect() uses a "should ..." message, json! throughout, Arrange-Act-Assert with descriptive assertion messages, vi.spyOn used directly so the vi.hoisted() rule doesn't apply. The real vendor domains in the test data (id5-sync.com, sharedid.org, ...) match existing precedent on main for EID source domains, and no real operator config values are introduced.

Praise

  • The Purpose 4 gate (finalize.rs:125-150) catches something the stated scope didn't ask for: the bid request was already being stripped, but the identity graph kept persisting EIDs the user had opted out of. finalize_withholds_eid_kv_writes_when_purpose_four_is_denied proves it.
  • The ec_id.is_some() gate on client_eids correctly covers the US/GPC opt-out case that gate_eids_by_consent alone would miss, and the comment explaining why is genuinely good.
  • The premise checks out end-to-end: buildRequests sends the untrimmed collectAuctionEids() in the body independent of fitAuctionEidsToCookie, so the fix recovers real data rather than theoretical data.

Verification I ran locally (scratch worktree, not the PR branch)

Command Result
cargo fmt --all -- --check PASS
cargo test -p trusted-server-core (native) PASS — 2703 passed, 0 failed; all 4 new tests green by name
cargo clippy-fastly (-D warnings --all-targets --all-features) PASS, no warnings
cargo check-axum / cargo clippy-cloudflare / cargo check-spin PASS
npx vitest run PASS — 960 tests, no type errors

Not run locally: cargo test-fastly under Viceroy, test-cloudflare, test-spin, the parity suite, and the format jobs — all green on CI for this head. GitHub CI is 20/20.

Filing the ungated public ingest_* API as a separate follow-up issue rather than PR work.

Comment on lines +74 to +79
let updates = collect_consent_gated_eid_updates(
eids_cookie,
sharedid_cookie,
ec_context,
registry,
);

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.

MEDIUM — did you intend Purpose 4 denial to also disable orphan-EC recovery?

upsert_partner_ids_from_snapshot early-returns the incoming snapshot untouched when updates is empty (ec/kv.rs:557-559), and it is the only thing on this path that performs the load_snapshot refresh (ec/kv.rs:582-591). Orphan recovery immediately below gates on that refreshed snapshot being Missing:

ec_context.set_kv_snapshot(snapshot);
if matches!(ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. })
    && ec_context.recovery_eligible()
{
    confirm_then_recover_orphaned_ec(...);
}

Failure scenario. A GDPR user with TCF Purpose 1 granted and Purpose 4 denied, with an orphaned EC, on a non-GET publisher navigation — e.g. a form POST. recovery_eligible comes from is_publisher_navigation = ec.is_real_browser && is_navigation_request(&req) (adapter-fastly/src/app.rs:858,905), which does not require GET, whereas should_preload_ec_snapshot(is_navigation, is_get, ...) (publisher.rs:2959-2966) does.

Before this PR their ts-eids cookie produced non-empty updates -> snapshot refreshed -> Missing -> recovery ran. After this PR the Purpose 4 gate empties updates -> snapshot stays NotRead -> recovery silently never fires, and their EC stays orphaned indefinitely.

Narrow — GET navigations preload the snapshot at publisher.rs:4419-4431 and are unaffected — but it's a real coupling: consent-gating the payload also disabled an unrelated liveness mechanism. If unintended, the fix is to decouple the refresh from the update list (resolve the snapshot before the gate, or call load_snapshot when updates.is_empty() && recovery_eligible()). No suggestion block since it touches kv.rs too.

registry: &PartnerRegistry,
) {
let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry);
let updates = collect_eid_updates(eids_cookie, sharedid_cookie, None, registry);

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.

MEDIUM — this public API still bypasses the new Purpose 4 gate

The ingest_eid_cookies / ingest_prebid_eids / ingest_sharedid_cookie family is pub, funnels through here, and writes to KV with no consent gating at all — while the path right next to it just gained a Purpose 4 gate.

Not exploitable today: these three have zero in-repo callers (only a comment reference in ec/admin.rs:602). But it leaves a public, ungated write path adjacent to a newly-gated one, which is the kind of divergence that gets picked up by accident later.

Either delete the dead pub surface or route it through collect_consent_gated_eid_updates. Not suggestion-eligible — it needs a ConsentContext threaded through three public signatures, or a cross-file deletion. Happy to file this as a follow-up issue instead of holding up the PR.

Comment on lines +512 to +522
/// Records the current request's own EIDs (e.g. an `/auction` body) for
/// use by response finalization's KV ingestion.
pub fn set_client_eids(&mut self, eids: Vec<Eid>) {
self.client_eids = Some(eids);
}

/// Returns the current request's own EIDs, if the route captured any.
#[must_use]
pub fn client_eids(&self) -> Option<&[Eid]> {
self.client_eids.as_deref()
}

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.

LOW — pub wider than needed

AGENTS.md: "Consider visibility carefully — avoid unnecessary pub." Both accessors are used only from auction/endpoints.rs:305 and ec/finalize.rs (plus tests), and replace_with_generated directly below is already pub(crate).

Scratch-verified as a pair: applied in a worktree, then cargo clippy-fastly (0 warnings), cargo check-axum, cargo clippy-cloudflare and cargo check-spin all clean.

Suggested change
/// Records the current request's own EIDs (e.g. an `/auction` body) for
/// use by response finalization's KV ingestion.
pub fn set_client_eids(&mut self, eids: Vec<Eid>) {
self.client_eids = Some(eids);
}
/// Returns the current request's own EIDs, if the route captured any.
#[must_use]
pub fn client_eids(&self) -> Option<&[Eid]> {
self.client_eids.as_deref()
}
/// Records the current request's own EIDs (e.g. an `/auction` body) for
/// use by response finalization's KV ingestion.
pub(crate) fn set_client_eids(&mut self, eids: Vec<Eid>) {
self.client_eids = Some(eids);
}
/// Returns the current request's own EIDs, if the route captured any.
#[must_use]
pub(crate) fn client_eids(&self) -> Option<&[Eid]> {
self.client_eids.as_deref()
}

Comment on lines +1983 to +1986
const dropped = payload.pop();
if (dropped) {
droppedSources.add(dropped.source);
}

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.

LOW — the warning under-reports partial EID loss

droppedSources only records sources removed wholesale by payload.pop(). The branch just above it — last.uids = last.uids.slice(0, last.uids.length - 1) — silently discards UIDs from a source that is retained, and that loss never reaches the warning. A page that trims 4 of a source's 5 UIDs logs nothing at all.

Minor, since the auction body carries the untrimmed set anyway. Raising it because the warning's whole purpose is diagnosing what the cookie dropped, and right now it's half-blind to that.

@ChristianPavilonis ChristianPavilonis left a comment

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.

Summary

Reviewed 94b8d18e8a6d3a2b17e831e8c8ba5d5784c25a52 against 2ca5d39ca7a5f600285f585435e169a5dad4dabe. The request-body EID ingestion and Purpose 4 write gate are supported by focused tests, and I found no additional actionable issues. Approving on the requested assumption that any blocking findings from other reviews are addressed before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KV Ingestion of IDs reads cookie at response instead of body EID array: truncation issue

3 participants