Skip to content

Prevent accidental Prebid Server stored requests - #1159

Open
ChristianPavilonis wants to merge 3 commits into
mainfrom
fix/pbs-stored-requests
Open

ChristianPavilonis wants to merge 3 commits into
mainfrom
fix/pbs-stored-requests

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Prevent generated browser auction slots from accidentally requesting Prebid Server (PBS) stored impressions. Previously, an empty bidder map could trigger a lookup using a dynamic slot code; a missing stored impression could reject the whole provider request, including valid sibling impressions.
  • Add optional trustedServer.params.storedRequest intent: false disables stored fallback, true permits it, and omission preserves legacy behavior. Filter demandless PBS impressions after server-side parameter overrides without suppressing eligible Amazon Publisher Services (APS) or other providers.
  • Preserve publisher intent across repeated and refresh auctions, with regression tests for the serialized browser payload and actual outbound provider request bytes.

PBS stored-request context

Stored requests are an existing optional Prebid Server feature. A caller normally opts in by referencing configuration already stored in PBS by ID. This PR does not introduce that feature or require new PBS configuration.

The defect was in Trusted Server's fallback behavior: when a slot had no usable inline bidder parameters, it could automatically use the dynamic slot code as a stored-impression ID. That accidentally opted generated slots into stored lookup. This fix makes newly generated envelopes explicitly opt out with storedRequest: false, while preserving intentional true and the legacy behavior for callers that omit the field.

Changes

Changed files
File Change
crates/trusted-server-core/src/auction/routing.rs Validates stored intent atomically, retains legacy admission facts, and carries intent into provider-local routing.
crates/trusted-server-core/src/auction/openrtb.rs Honors intent after bidder overrides, filters paired impressions safely, and skips empty requests before signing or transport.
crates/trusted-server-core/src/auction/openrtb/tests.rs Covers explicit, disabled, and legacy fallback, override-populated params, and retained impression identity.
crates/trusted-server-core/src/auction/orchestrator.rs Adds transport tests for zero, one, and two active PBS providers alongside APS, including valid bid preservation.
crates/trusted-server-core/src/auction/formats.rs Documents the wire field and tests conversion through strict intent admission.
crates/trusted-server-js/lib/src/integrations/prebid/index.ts Defaults newly generated envelopes to false and preserves authored intent in live ad units and immutable refresh snapshots.
crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts Tests initial, repeated, and refresh serialization, including omitted and malformed intent and container-ID lookup.
crates/trusted-server-js/lib/test/core/auction.test.ts Confirms the shared serializer retains boolean values, omission, and invalid null for server validation.
crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs Verifies generated and publisher-authored intent through the built adapter.
docs/guide/api-reference.md Defines the field, validation behavior, legacy compatibility, and request examples.
docs/guide/integrations/prebid.md Explains refresh behavior, server-first deployment, and rollback constraints.
docs/guide/auction-orchestration.md Describes provider routing and post-override impression filtering.
CHANGELOG.md Records the fix and deployment requirement.
docs/superpowers/plans/2026-09-10-pbs-stored-request-intent.md Records the approved plan, scope correction, regression evidence, and verification results.

Scope

This touches 14 files because the intent must survive browser generation, refresh reconstruction, server routing, and final request construction. Most additions are regression tests and documentation; changing only the router would leave a second stored-request fallback in the request builder.

No dependencies, adapter implementations, provider configuration, browser provider selectors, or explicit stored IDs are changed. Legacy inference and intentional stored-demand fanout remain supported. PBS all_eligible configuration remains rejected by the existing compiler, so its boundary test is retained rather than enabling a new configuration mode.

Deployment

Deploy server support everywhere before serving the new JavaScript. The old router rejects unknown envelope fields and can drop valid inline demand when it receives storedRequest.

Rust artifacts embed the JavaScript bundles. First prepare a server-support-only build retaining old JS emission, then distribute the full build. Keep compatible server admission during rollback while browsers may retain the new JS. Existing legacy callers and intentional stored requests can still reference missing slot-code IDs; this change does not eliminate unrelated PBS validation errors.

Closes

Closes #1086

Test plan

Meaningful failing-before assertions were captured for explicit intent admission, generated JS serialization, and the remaining post-override fallback. They pass after the fix. Independent correctness review found no issues.

  • cargo test-fastly && cargo test-axum
  • cargo test-cloudflare && cargo test-spin
  • ./scripts/test-cli.sh
  • cargo clippy-fastly && cargo clippy-axum
  • cargo clippy-cloudflare && cargo clippy-cloudflare-wasm
  • cargo clippy-spin-native && cargo clippy-spin-wasm
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run, 923 tests passed
  • JS lint/format/build: npm run lint, npm run format, and node build-all.mjs
  • Docs format/build: cd docs && npm run format && npm run build
  • git diff --check
  • Standalone release WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve

Fastly tests ran through Viceroy; Cloudflare and Spin production WASM paths passed Clippy. The full Rust suites passed with 10 pre-existing Fastly tests/doctests ignored. After a mechanical Clippy fix and a test-only example-domain correction, all six Clippy checks and focused routing/OpenRTB tests were rerun successfully; the full suites were not repeated after those two edits. No live PBS service was contacted.

Checklist

  • Changes follow the project coding conventions
  • No new unwrap() in production code; use expect("should ...")
  • Existing log instrumentation conventions preserved; no println! added
  • New behavior has regression tests
  • No secrets or credentials committed

ChristianPavilonis added a commit that referenced this pull request Sep 16, 2026
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review September 16, 2026 15:08
@ChristianPavilonis
ChristianPavilonis requested review from aram356 and prk-Jr and removed request for aram356 September 16, 2026 15:08
@aram356 aram356 added this to the 202609 milestone Sep 17, 2026
aram356 added a commit that referenced this pull request Sep 18, 2026

@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.

Summary

Reviewed commit 1e89a976a205bde3feaa9a599f725d99454afecf.

The explicit intent contract, provider-local filtering, and browser refresh preservation are well covered. One response-admission regression remains: PBS parse state still admits impressions removed from the outgoing request, allowing an unsolicited bid for an omitted slot to win.

Blocking

  • 🔧 Restrict response admission to impressions actually sent — see inline at crates/trusted-server-core/src/auction/openrtb.rs:515–516.

Validation

Local checks passed: 222 JS tests, 16 routing tests, 29 OpenRTB tests, one orchestrator transport test, and the 13-module bundle build. An additional scratch transport regression returned a bid for the omitted fictional-slot; the assertion that it cannot win failed, confirming the finding. Scratch changes were restored. No live PBS service was contacted; general adapter/lint gates rely on remote CI.

CI Status

Comment on lines +515 to +516
if prebid.is_empty() {
return None;

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.

🔧 [P2] Restrict response admission to impressions actually sent

Dropping an impression here does not remove it from the response-admission input. GenericOpenRtbProvider::request_bids_routed still clones the original ProviderAuctionInput into PBS parse state (provider.rs:401-405), and PBS builds its allowed impression/dimension index from that input (integrations/prebid.rs:2066). With one disabled empty candidate beside a valid inline sibling, the wire request contains only the sibling, but a PBS response naming the dropped candidate is accepted and can win the auction. This breaks the existing rejection of unrequested impressions precisely for the new post-override filtering path.

Carry the emitted impression membership into the provider's response state, and exclude dropped slots before building its dimension index. Add a mixed-request regression that returns both a valid sibling bid and a bid for the omitted impression; only the sibling should survive and the omitted impression should count as unrequested_impression.

Proposed implementation shape (manual, illustrative API):

let sent_impression_ids = request.imp.iter()
    .filter_map(|imp| imp.id.as_deref())
    .collect::<HashSet<_>>();
let response_input = input.filtered_slots(|slot| {
    sent_impression_ids.contains(slot.slot().id.as_str())
});
// Store response_input, rather than input.clone(), in the PBS parse state.

Apply manually — this needs a filtered-input helper or equivalent parse-state change across routing/provider code plus a regression test, outside the changed OpenRTB hunk. No one-click suggestion is eligible; the illustrative API above is not an implemented or verified replacement.

@aram356 aram356 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

The mechanism is correct and the tests are load-bearing rather than decorative. I verified locally against the PR head: 2675 core tests and 981 JS tests pass, cargo fmt --all -- --check clean, cargo clippy-axum clean, eslint/prettier clean. All 20 remote checks pass.

I checked the two hunks that read like regressions and both are safe. The AllEligible branch disappearing for prebid providers (routing.rs:376-382) is dead code, because plan.rs:307 rejects all_eligible + prebid-server at compile time. The zip over a filter_map-built impression list cannot misalign, because eligible_banner_slot guarantees build_imp never returns None for a routed slot. Neither is obvious from the diff, so both deserve a sentence in the PR description.

The deployment-ordering warning in the docs is accurate and load-bearing: I confirmed the base normalize_envelope rejects storedRequest as an unknown key, which drops bidderParams and zone for that slot. Old server plus new JS is a real demand-loss hazard, correctly identified.

One blocking finding: replacing a debug_assert! invariant with a routine production path that drops demand and emits no metadata, no log, and no counter.

1 of the inline comments below carries a one-click GitHub suggestion — use Commit suggestion to apply it as a commit on the PR branch. The remaining comments describe the fix in prose because the change spans multiple files or lines outside the diff and can't be auto-applied.

Blocking

🔧 wrench

  • NoImpressions drops demand with no metadata, log, or counter — see Cross-cutting below

Non-blocking

🤔 thinking / ♻️ refactor / 📝 note / ⛏ nitpick

  • The original defect is still live for un-upgraded publishers — see Cross-cutting below
  • storedRequestParams({ params: snapshot }) launders a snapshot through a fake bid — see inline at crates/trusted-server-js/lib/src/integrations/prebid/index.ts:842
  • undefined and null authored intent diverge silently — see inline at crates/trusted-server-js/lib/src/integrations/prebid/index.ts:707
  • auction.test.ts case passes with the entire PR reverted — see inline at crates/trusted-server-js/lib/test/core/auction.test.ts:94
  • allows_stored_fallback's has_candidates argument is dead in production — see inline at crates/trusted-server-core/src/auction/routing.rs:187

Cross-cutting / body-level findings

  • 🔧 NoImpressions drops demand with no metadata, log, or counter — This PR deletes debug_assert!(!prebid.is_empty(), "should never route a demandless slot to prebid-server") and turns that same condition into a routine production path via the new filter in apply_prebid plus the early return at openrtb.rs:266-268.

    That path lands on an unchanged call site, crates/trusted-server-core/src/auction/provider.rs:296-301, which returns a bare AuctionResponse::no_bid(self.provider_name(), 0) with empty metadata. To an operator that is indistinguishable from PBS legitimately returning no bids. Partial drops are invisible too: if 3 of 4 impressions are filtered but 1 survives, the request goes out as Ready and nothing records that 3 were dropped.

    The reachability change is what makes this matter. On base, NoImpressions could only fire when a provider had zero eligible slots from the start, and that case is already annotated upstream by provider_skipped_response (orchestrator.rs:218-223). This PR adds a second, semantically different trigger — demand filtered out after provider-local overrides — and routes it into the same metadata-free response. Once new TSJS ships storedRequest: false on every generated envelope, a misconfigured [auction.bidders] route produces no PBS request, no metadata, no log line, and no counter.

    The repo already has both idioms to fix this, so it is a small in-pattern change rather than a redesign. Minimum fix at provider.rs:296 (apply manually — provider.rs is outside this PR's diff, so it can't be a suggestion):

    OpenRtbBuildOutcome::NoImpressions => {
        return Ok(ProviderRequestOutcome::Immediate(
            AuctionResponse::no_bid(self.provider_name(), 0).with_metadata(
                "routing",
                serde_json::json!({"skipped_no_usable_demand": true}),
            ),
        ));
    }

    Better still, count the partial drops in apply_prebid using the existing RoutingDiagnostics saturating-counter pattern (routing.rs:70-105), which would restore the signal the debug_assert used to provide. pbs_disabled_empty_candidate_does_not_become_stored_demand and the new orchestrator transport test are the natural places to pin it.

  • 🤔 The original defect is still live for un-upgraded publishers — I verified this with a scratch test rather than inferring it. Routing the envelope {"bidderParams":{"exampleBidder":{}}} with no storedRequest key through build_request still produces:

    imp[0].ext.prebid = {"storedrequest":{"id":"fictional-slot"}}
    

    That is the reported defect — a stored-impression lookup keyed by the dynamic slot code — for any publisher who has not yet received the new JS.

    This is deliberate, not an oversight: base had || !slot.bidder_params().is_empty() in apply_prebid, and the PR faithfully preserves it as the has_candidates arm of StoredRequestIntent::Legacy. The pre-existing test openrtb/tests.rs:724 (pbs_empty_params_without_matching_override_fall_back_to_stored_request) pins exactly this shape, and creative_opportunities.rs:891 depends on legacy inference, so the compatibility choice is sound.

    The concern is only how it is described. The fix is effective once new JS reaches browsers, which the deployment section correctly says is gated on cached clients. "Closes #1086" reads as fully closed, and the escape hatch — any envelope with non-empty bidderParams whose entries are all empty objects — is not called out in the PR body or the api-reference table. Worth one sentence so operators know the server-side hole persists until client rollout completes.

CI Status

  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • cargo test (axum native): PASS
  • cargo test (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native): PASS
  • vitest: PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • browser integration tests: PASS
  • prepare integration artifacts: PASS
  • CodeQL: PASS
  • Analyze (rust): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (actions): PASS
  • CLAUDE.md symlink guard: PASS

Comment on lines +841 to +843
const snapshot = findRefreshSnapshot(candidateCodes);
return snapshot ? storedRequestParams({ params: snapshot }) : { storedRequest: false };
}

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.

♻️ refactor — This launders a PublisherAdUnitSnapshot through a synthetic { params: ... } bid to reuse storedRequestParams. It is correct today, but only because of an invisible coupling.

It works because this PR hoisted storedRequest to the snapshot's top level (index.ts:385, as a sibling of bidderParams / clientSideBids / zone), so hasOwnProperty.call(bid.params ?? {}, STORED_REQUEST_KEY) at index.ts:706 happens to read the right field. TypeScript cannot catch a violation, because PublisherAdUnitSnapshot is structurally assignable to Record<string, unknown> and the value type is unknown.

The failure mode is silent and lands exactly on this PR's purpose. If someone later restructures the snapshot to { params: {...}, clientSideBids: [...] } — a natural refactor — this line starts returning {} for every snapshot-backed refresh, flipping those slots from "stored fallback disabled" to legacy inference and re-enabling the accidental stored requests this PR exists to prevent. No type error, and no existing test fails: I checked, and every current test moves the write site (index.ts:743) and this read site together, so they only break on an inconsistent change.

Reading the field directly at the point of use makes the dependency explicit:

Suggested change
const snapshot = findRefreshSnapshot(candidateCodes);
return snapshot ? storedRequestParams({ params: snapshot }) : { storedRequest: false };
}
const snapshot = findRefreshSnapshot(candidateCodes);
if (!snapshot) return { storedRequest: false };
return Object.prototype.hasOwnProperty.call(snapshot, STORED_REQUEST_KEY)
? { storedRequest: copyParamValue(snapshot[STORED_REQUEST_KEY]) }
: {};
}

Scratch-verified on the PR head: npx vitest run 981 passed / 45 files, npm run lint (eslint --max-warnings=0) clean, prettier --check clean, and tsc --noEmit reports nothing for this file. Byte-exact pre/post patch comparison showed no drift.

function storedRequestParams(bid: TrustedServerBid | undefined): { storedRequest?: unknown } {
if (!bid) return { storedRequest: false };
return Object.prototype.hasOwnProperty.call(bid.params ?? {}, STORED_REQUEST_KEY)
? { storedRequest: copyParamValue(bid.params?.[STORED_REQUEST_KEY]) }

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.

🤔 thinkingundefined and null are both "the publisher wrote nonsense here", but they take completely different paths, and the difference is invisible at this line.

I traced both through the real serialization path (buildRequestsbuildAdRequestJSON.stringify) and confirmed by running it:

  • storedRequest: undefinedhasOwnProperty is true for an explicitly-assigned undefined, so this returns { storedRequest: undefined }. JSON.stringify then drops the key, the server sees omission, and the slot gets legacy inference.
  • storedRequest: null — serializes as "storedRequest": null, which hits Some(_) => return None in normalize_envelope. The envelope is rejected atomically, so that slot loses its bidderParams and its zone for PBS.

So a publisher writing storedRequest: config.enableStored with a missing config key silently gets legacy behavior, while storedRequest: config.enableStored ?? null silently loses all of that slot's PBS demand. The only evidence of the second case is a server-side record_malformed_envelope counter the publisher cannot see.

Forwarding invalid values verbatim for server validation is a defensible design and the docstring says so. Being silent about it in the browser is the part worth reconsidering — a one-time log.warn when the authored value is not a boolean would turn a silent demand drop into something diagnosable:

const authored = bid.params?.[STORED_REQUEST_KEY];
if (typeof authored !== 'boolean') {
  log.warn('[tsjs-prebid] non-boolean storedRequest will be rejected by the server', authored);
}

Separately, undefined is not covered by either it.each list (index.test.ts:1253, index.test.ts:2793) or by auction.test.ts:93. Given it is the one non-boolean that behaves differently from all the others, it is the most valuable missing case. Note that asserting it needs not.toHaveProperty on the parsed wire object rather than toEqual, since toEqual treats {a:1, b:undefined} and {a:1} as equal.

Apply manually — the warn helper and the added test case live outside this hunk.

});

it.each([{}, { storedRequest: false }, { storedRequest: true }, { storedRequest: null }])(
'retains stored intent presence in serialized shared requests: %j',

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.

nitpick — This case passes with the entire PR reverted, so the title credits it with coverage it does not provide.

buildAdRequest (src/core/auction.ts:92-104) copies params by reference and neither filters nor rewrites keys, and src/core/auction.ts is not in this PR's diff. The test therefore pins a pre-existing pass-through property rather than anything this change introduces.

That is still reasonable regression insurance — it proves the shared serializer will not strip or mangle the new key — but "retains stored intent presence in serialized shared requests" reads like coverage of the new feature, and a reviewer scanning test names would count it as such. Renaming to something like buildAdRequest passes bid params through verbatim keeps the value and drops the implication.

Also note the toEqual assertion here cannot distinguish "key absent" from "key present with value undefined", which is the distinction that matters for the omission case. prebid-artifact-integration.test.mjs:229 gets this right with not.toHaveProperty on the post-JSON.parse wire object.

Apply manually — rename only, outside the suggestion-eligible range.

self.bidder_params.is_empty() && self.allows_stored_fallback()
}

pub(crate) fn allows_stored_fallback(&self) -> bool {

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.

📝 note — The has_candidates parameter is dead at the only production call site.

allows_stored_fallback passes !self.bidder_params.is_empty(), but the one non-test caller is the else if slot.allows_stored_fallback() arm in apply_prebid (openrtb.rs:512), which is reached only when the computed bidder map came out empty. So has_candidates == true is only reachable there when the slot has routed bidder params that all failed the post-override usability filter.

That is not a bug — it is exactly how base's || !slot.bidder_params().is_empty() behaved, and preserving it is the point. Worth a short comment on StoredRequestIntent::allows_fallback recording why Legacy consults has_candidates, since the parameter otherwise looks vestigial to a future reader and is an easy thing to "simplify" away — doing so would change the legacy fallback semantics that openrtb/tests.rs:724 depends on.

Apply manually — a doc comment above allows_fallback in routing.rs:256-264, outside this hunk.

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.

Distinguish empty PBS demand from stored-request intent

3 participants