Dump full SSAT prebid responses in ts-debug auction comment#890
Conversation
The server-side auction stream path only emitted a summary counter (ssp/mediator/winning/time), so an operator seeing winning=0 could not tell whether prebid returned nothing, errored, or bid below the floor. Serialize the full provider_responses (and mediator_response) into the ts-debug HTML comment so the SSAT surfaces the same prebid server response detail available from the /auction endpoint. Bid creative and metadata are attacker/partner-influenced, so neutralize the '-->' and '--!>' comment terminators before embedding to keep the dump inside the comment and out of the live DOM.
When Prebid Server returns a non-2xx status, the parser returned a bare
AuctionResponse::error with empty metadata — indistinguishable in the
ts-debug dump from a transport, parse, or timeout failure, all of which
tag error_type. An operator seeing status=error with metadata={} had no
way to know the upstream HTTP code without log access.
Attach error_type=http_status, the status code, and a 512-byte body
snippet to the error response metadata so the auction dump shows exactly
why prebid errored (e.g. a 4xx from a PBS rejecting the request).
aram356
left a comment
There was a problem hiding this comment.
Summary
The HTML-comment side of this PR is sound — I fuzzed the terminator neutralisation against a real HTML5 parser (11 escape vectors, including the <!--> / <!--!> nested-comment paths) and nothing escaped the comment. The problem is the other half: the new prebid error metadata is not confined to the debug comment. It flows into the public POST /auction response, which breaks an invariant the codebase documents explicitly.
Blocking
🔧 wrench
- Upstream PBS error body is echoed into the public
/auctionresponse (integrations/prebid.rs:2143):.with_metadata("body", …)lands inProviderSummary.metadata(auction/types.rs:252) →ext.orchestrator.provider_details[](auction/formats.rs:305), ungated by any debug flag.auction/orchestrator.rs:100carries a// SECURITY:comment forbidding exactly this: "Providers MUST NOT interpolate upstream-controlled content (response bodies, parse errors, headers)… Use static text and log details server-side withlog::warn!instead." Verified by serializing the resulting summary — the body snippet appears on the wire. See inline comment for the fix. error_type: "http_status"is mislabelled as a transport error in telemetry (integrations/prebid.rs:2141): it isn't one of the fourERROR_TYPE_*consts (auction/orchestrator.rs:95-98), soprovider_status()(auction/telemetry.rs:795) hits its_ => "transport_error"fallback. Every PBS 4xx/5xx is now bucketed with genuine connection failures — which defeats this PR's own goal on the telemetry path.[debug].auction_html_commentdoc no longer describes what the flag does (settings.rs:1904): it still promises "auction pipeline stats (SSP count, mediator status, winning bid count)", but the comment now embeds every bid's full raw SSP creative markup. Operators make the enable/disable call from that sentence. The siblingadm_in_bidsflag already carries the right warning ("Never enable in production — injects raw HTML from SSPs") — please mirror it here, and update theprepend_auction_debug_commentdoc atpublisher.rs:858, which still says "summarising".
Non-blocking
🤔 thinking
- Unbounded dump size (
publisher.rs:885): every bid'screative— including losers — pretty-printed into every page render. See inline.
♻️ refactor
- A single
replace("--", "- -")would replace the two-terminator blacklist (publisher.rs:884) — equivalent, trivially auditable, and XHTML-safe. Inline comment has the fuzz results. - Test coverage gap:
--!>and the nested-comment paths are handled but unpinned (publisher.rs:2502). - Local imports inside test fn bodies violate CLAUDE.md (
publisher.rs:2465,integrations/prebid.rs:2355).
⛏ nitpick
mediator_response=nullis dumped when there is no mediator, duplicatingmediator=none(publisher.rs:888).
🌱 seedling
path_labelonly ever receives"stream"(publisher.rs:1265), though the doc says it differentiatesstreamfrombuffered. The buffered path produces no dump at all, so an operator debugging that path gets nothing from this feature. Pre-existing, not introduced here — worth a follow-up.
CI Status
All 19 checks pass on 08de9c4 (fmt, clippy, cargo test across fastly/axum/cloudflare/spin/parity/CLI, vitest, docs format, integration + browser tests, CodeQL).
Address PR review of the SSAT debug-dump change:
- The PBS non-2xx response body was attached to AuctionResponse.metadata,
which ProviderSummary clones verbatim into ext.orchestrator.provider_details
on the public /auction response — violating the documented invariant in
auction/orchestrator.rs. Drop the body from metadata, keep only the numeric
status, and log the snippet server-side at warn.
- Register ERROR_TYPE_HTTP_STATUS and match it in provider_status so PBS HTTP
errors get their own telemetry bucket instead of the transport_error
fallback.
- Bound the ts-debug dump: compact serialization capped at 256 KiB, and skip
the mediator_response line when no mediator ran.
- Correct the auction_html_comment and prepend_auction_debug_comment docs to
state the comment now embeds raw SSP creative markup (never enable in prod).
- Keep the targeted two-replace terminator neutralisation: a single
replace("--", ...) re-forms -->/--!> at odd dash-run junctions and is not
equivalent. Add a table-driven test over the comment-terminator vectors.
- Hoist test-local imports to module scope per CLAUDE.md.
…debug' into feat/ssat-write-prebid-response-debug
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Approved after reviewing the current head against main. The HTML-comment containment and public-response protections appear correct. I noted two follow-up findings inline: one telemetry aggregation gap and one edge-runtime resource concern.
| Some("parse_response") => "parse_error", | ||
| Some("transport") => "transport_error", | ||
| Some("timeout") => "timeout", | ||
| Some("http_status") => "http_status_error", |
There was a problem hiding this comment.
🔧 P1 / High — PBS HTTP failures are excluded from provider-health error metrics
The new http_status_error value is emitted here, but tinybird/pipes/auction_provider_stats_mv.pipe:15 only counts launch_error, parse_error, and transport_error as errors. These requests therefore increase requests without increasing errors, timeouts, or another outcome counter, and provider_health.error_rate under-reports PBS 4xx/5xx failures. The documented status contract at docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md:347 is also stale.
Suggested fix: Add http_status_error to the Tinybird rollup's error set, update the documented status list, and add coverage for both the Rust mapping and downstream aggregation classification.
| // the junction of an odd dash-run (`--->` -> `- -->`, `----->` -> `- -- -->`), | ||
| // reintroducing exactly the terminator we are trying to remove. The two | ||
| // targeted replacements below cannot re-form either sequence. | ||
| let render_dump = |json: String| -> String { |
There was a problem hiding this comment.
🤔 P2 / Medium — The 256 KiB cap does not bound serialization work or transient allocations
serde_json::to_string materializes the complete provider response before this closure runs, and the chained replace calls then allocate and scan full-size strings before truncation. Provider responses can each reach 2 MiB, and mediator serialization repeats the process separately. On Wasm edge runtimes this can create substantial transient heap pressure and latency despite the final output cap; with a mediator, the final comment can also exceed 512 KiB because each section receives its own budget.
Suggested fix: Serialize through a bounded writer, apply one explicit total-comment budget, then neutralize the bounded buffer. Add an oversized multibyte provider-plus-mediator test verifying bounded output, valid UTF-8, the truncation marker, and comment containment.
aram356
left a comment
There was a problem hiding this comment.
Summary
Second, deeper pass across the surfaces the first review didn't reach (privacy/PII content of the dump, every egress channel, runtime/memory/caching, and the test surface). It turned up two problems that outrank everything in my previous review.
The short version: with prebid debug = true, this dump renders the visitor's identity graph — EC ID, EIDs, TC consent string, client IP, and geo lat/lon — into the publisher's page source, where any third-party tag on the page can read it. And because the dump is unbounded, it can push the page over the buffered-body cap and turn a working page into a 500.
Blocking
🔧 wrench
- The dump renders the visitor's identity graph into the page HTML (
publisher.rs:874-890).AuctionResponse.metadatacarries PBS's entireext.debugsubtree, copied verbatim atintegrations/prebid.rs:1831-1836when[integration.prebid].debug = true— that isresolvedrequest(the full OpenRTB request TS sent) andhttpcalls(the outbound body sent to each bidder). That request is built atintegrations/prebid.rs:1514-1600and containsuser.id(EC ID),user.ext.eids, the raw TC string,device.ip, anddevice.geo.lat/lon. Serializingmetadatainto a DOM comment node hands the consent-gated identity graph straight back to the client-side script environment that server-side EID resolution exists to keep it out of. Reproduction below. See inline. - The unbounded dump can convert a working page into a 500 (
publisher.rs:892). Output is written through aBoundedWritercapped atpublisher.max_buffered_body_bytes(default 16 MiB,settings.rs:71-73);write()returnsErrpast the cap (publisher.rs:574-583) and that surfaces asTrustedServerError::Proxy→ 5xx. The whole comment goes out in oneend_tag.before()write. WithUPSTREAM_RTB_MAX_RESPONSE_BYTESat 2 MiB per provider (integrations/mod.rs:163) andhttpcallscarrying full per-bidder request/response bodies, multi-MB dumps are realistic — so the debug flag can break the page it was enabled to debug. Every other partner-influenced payload here is bounded (MAX_AUCTION_BODY_SIZE256 KiB,MAX_CREATIVE_SIZE1 MiB — and this PR's own 512-byte snippet). This dump is the one that isn't. See inline. - The new prebid test asserts the leak as intended behaviour (
integrations/prebid.rs:2383-2389). Fixing the/auctionbody exposure from my previous review means this assertion has to be deleted or inverted — flagging so it isn't mistaken for a regression. See inline.
Non-blocking
♻️ refactor
- The new error response omits
message(integrations/prebid.rs:2140), the one key every other error path sets (auction/orchestrator.rs:123, 129, 141, 147). The prebid HTTP-error shape now diverges from every other provider error. See inline. --!>neutralisation is untested. Delete.replace("--!>", "-- !>")frompublisher.rs:884and the suite stays green — the fixture only plants-->inBid.creative. Half the claimed protection is unguarded. Also worth planting a terminator inmetadata(which the code comment atpublisher.rs:879-882explicitly calls out as attacker-influenced, but the test never exercises) and using aSome(_)mediator so the secondneutralisecall atpublisher.rs:888-890is covered at all.
⛏ nitpick
- The serialize-error fallback bypasses the neutraliser (
publisher.rs:887, 890) —.map()applies only to theOkbranch. Unreachable in practice, but it's a latent seam in a security-relevant path. See inline. - Non-deterministic output.
AuctionResponse.metadataandwinning_bidsareHashMaps, so key order varies per render and two page sources of the same auction won't diff cleanly.
📌 out of scope
auction_html_commentis undocumented. Zero hits acrossdocs/, and it is absent from the[debug]block intrusted-server.example.toml(which lists onlyja4_endpoint_enabled). There is no operator-facing warning anywhere for a flag that now publishes creatives and, with prebid debug on, user identifiers.
Reproduction — identity graph in page source
Drop into publisher.rs's test module and run cargo test -p trusted-server-core --target wasm32-wasip1 --lib -- probe_identity_graph --nocapture. It passes on this branch — every needle is present in the injected HTML:
#[test]
fn probe_identity_graph_lands_in_page_html() {
// Shape of PBS `ext.debug` as prebid.rs:1831-1836 stores it verbatim.
let prebid = crate::auction::types::AuctionResponse::error("prebid", 12).with_metadata(
"debug",
serde_json::json!({
"resolvedrequest": {
"user": {
"id": "EC-ID-abc123",
"consent": "CPtc-TCSTRING-xyz",
"ext": { "eids": [{ "source": "example.com",
"uids": [{ "id": "EID-USER-999" }] }] }
},
"device": { "ip": "203.0.113.77",
"geo": { "lat": 37.7749, "lon": -122.4194 } }
}
}),
);
let result = crate::auction::orchestrator::OrchestrationResult {
provider_responses: vec![prebid],
mediator_response: None,
winning_bids: std::collections::HashMap::new(),
total_time_ms: 12,
metadata: std::collections::HashMap::new(),
};
let state = Arc::new(Mutex::new(Some("<script>BIDS</script>".to_string())));
prepend_auction_debug_comment("stream", &result, &state);
let page = state.lock().expect("should lock").clone().expect("should have html");
for needle in ["EC-ID-abc123", "EID-USER-999", "CPtc-TCSTRING-xyz", "203.0.113.77", "37.7749"] {
assert!(page.contains(needle), "leaked into page HTML: {needle}");
}
}Checked and not a problem
Cross-user cache poisoning. publisher.rs:1671-1683 forces Cache-Control: private, max-age=0 and strips Surrogate-Control on any ad-stack HTML, which is exactly the condition under which this comment can exist — so one visitor's dump cannot be shared-cached and served to another. Flagging explicitly so nobody spends time on it. The exposure is same-page JS, not the CDN.
Also clean: GET /__ts/page-bids, window.tsjs.bids, the telemetry event rows, and the mediator request all read Bid fields rather than AuctionResponse.metadata, so none of them carry the new data.
Why CI's 19 green checks caught none of this
Both this and the /auction exposure from my previous review live in cross-module data-flow contracts with no test at any boundary. Every formats.rs fixture hardcodes metadata: HashMap::new() and BidStatus::Success (auction/formats.rs:418, 448), so no error response with metadata is ever pushed through convert_to_openrtb_response; the one exact-JSON wire test hardcodes provider_details: vec![] (openrtb.rs:212); and provider_status() has a _ => catch-all (auction/telemetry.rs:803) which by construction cannot fail on an unrecognised error_type. It's an assertion gap, not an execution gap — the tests do run.
Suggested minimal fix
Whitelist what goes into the dump instead of serializing the whole metadata map: keep error_type, status, responsetimemillis, errors, warnings, bidstatus; exclude debug outright (or scrub resolvedrequest + httpcalls[].requestbody). Then cap the serialized payload — 256 KiB matching MAX_AUCTION_BODY_SIZE, with a <truncated N bytes> marker, and truncate Bid.creative per bid. That closes the identity exposure, the 5xx risk, and the memory pressure in one change, and it is consistent with the 512-byte cap this same PR already applies at integrations/prebid.rs:2139.
| /// tell which code path produced the bids. | ||
| pub(crate) fn prepend_auction_debug_comment( | ||
| path_label: &str, | ||
| result: &crate::auction::orchestrator::OrchestrationResult, |
There was a problem hiding this comment.
🔧 wrench — This comment advertises ext.debug.httpcalls as the feature, and that is exactly the problem: it renders the visitor's identity graph into the publisher's page source.
integrations/prebid.rs:1831-1836 copies PBS's entire ext.debug subtree into AuctionResponse.metadata verbatim when [integration.prebid].debug = true:
if self.config.debug {
if let Some(debug) = ext.and_then(|e| e.get("debug")) {
auction_response.metadata.insert("debug".to_string(), debug.clone());That subtree is resolvedrequest (the full OpenRTB request Trusted Server sent) and httpcalls (the outbound body sent to each bidder). That request is built at integrations/prebid.rs:1514-1600 and carries:
| Field | Source | Content |
|---|---|---|
user.id |
prebid.rs:1515 |
EC ID |
user.ext.eids |
prebid.rs:1528 |
the consent-gated identity graph |
user.consent |
prebid.rs:1516-1519 |
raw TCF TC string |
device.ip |
prebid.rs:1583 |
real client IP |
device.geo.lat/lon |
prebid.rs:1584-1600 |
precise geo |
Before this PR the comment held four counters. Now it serializes metadata, so all of the above lands in a DOM comment node — readable by every third-party tag already running on the page via document.documentElement.innerHTML, and present in view-source, scrapers, and archives.
This inverts the purpose of server-side EID resolution: the identity graph is resolved and consent-gated on the edge specifically so it does not reach the client script environment, and this hands it straight back. Note the codebase already redacts in the equivalent situation — auction/telemetry.rs:115-116 ships consent_present: bool, never the raw TC string.
I verified this rather than inferring it; a runnable reproduction is in the review body — it passes on this branch.
Fix — whitelist keys instead of dumping the map. Keep error_type / status / responsetimemillis / errors / warnings / bidstatus; exclude debug entirely, or scrub resolvedrequest and httpcalls[].requestbody before embedding.
| // `Bid.creative` and provider metadata are attacker/partner-influenced and | ||
| // may contain an HTML5 comment terminator (`-->`, or the `--!>` | ||
| // comment-end-bang variant), which would end the comment early and leak the | ||
| // remaining markup into the live DOM. Neutralise both, then cap the size so |
There was a problem hiding this comment.
🔧 wrench — The unbounded dump can turn a working page into a 500.
The whole comment is emitted in a single end_tag.before(&bids_script, ContentType::Html) (html_processor.rs:365), and that write lands in a BoundedWriter:
// publisher.rs:507
let mut output = BoundedWriter::new(settings.publisher.max_buffered_body_bytes);
// publisher.rs:574-583
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.inner.len() + buf.len() > self.limit {
return Err(std::io::Error::other("publisher body exceeded maximum buffered size"));Default limit is 16 MiB (settings.rs:71-73). The error propagates through write_processed_chunk as TrustedServerError::Proxy → 5xx. So enabling this flag can break the very page it was enabled to debug — and it fails at the </body> hold, after the page has already begun streaming.
The size is realistic, not theoretical: UPSTREAM_RTB_MAX_RESPONSE_BYTES allows 2 MiB per provider (integrations/mod.rs:163), Bid.creative is the raw adm retained for every bid from every provider (losers included), to_string_pretty adds indentation and escapes every " in that HTML, and with prebid debug on, metadata.debug.httpcalls carries the full request and response body for every bidder. The two chained String::replace calls then keep ~3 full copies of the payload live simultaneously, against a Wasm heap this repo's own design notes put at ~16 MB with a 3-4 MB baseline.
Every other partner-influenced payload in this codebase is bounded — MAX_AUCTION_BODY_SIZE (256 KiB, auction/endpoints.rs:42), MAX_CREATIVE_SIZE (1 MiB, creative.rs:310), SIGN_MAX_BODY_BYTES (64 KiB, proxy.rs:38) — and this PR itself caps the error snippet at 512 bytes. This dump is the one thing that isn't capped.
Fix — truncate Bid.creative per bid and cap the total serialized dump (256 KiB, matching MAX_AUCTION_BODY_SIZE) with a <truncated N bytes> marker.
| assert_eq!( | ||
| result.metadata["status"], | ||
| json!(403), | ||
| "should surface the upstream HTTP status code" |
There was a problem hiding this comment.
🔧 wrench — Heads-up: this assertion pins the security bug in place.
It asserts that the raw upstream response body is retained in metadata — and metadata is cloned verbatim into ext.orchestrator.provider_details[].metadata of the public POST /auction response (see my comment on line 2143). So fixing that exposure requires deleting or inverting this assertion.
Calling it out so the change doesn't read like a regression when you make it. The replacement test worth having is the inverse:
#[test]
fn parse_response_omits_upstream_body_from_public_metadata() {
// … 403 with a body …
assert!(
!result.metadata.contains_key("body"),
"upstream response body must not reach ProviderSummary.metadata — \
it is serialized into the public /auction response"
);
}Worth noting the surrounding module already has the gating pattern this path should have followed: enrich_response_metadata_includes_debug_fields_when_enabled (prebid.rs:5877) tests exactly that shape for the config.debug-gated keys.
| &body_preview[..body_preview.floor_char_boundary(512)] | ||
| ); | ||
| return Ok(AuctionResponse::error("prebid", response_time_ms) | ||
| .with_metadata( |
There was a problem hiding this comment.
♻️ refactor — This is the only provider error response in the codebase that omits message.
All four orchestrator error constructors set it (auction/orchestrator.rs:123, 129, 141, 147), so every consumer of provider_details[].metadata can currently rely on error_type + message being present. This path breaks that shape.
Adding a static message (per the SECURITY: note at auction/orchestrator.rs:100-105 — static text only, no upstream content) restores parity and gives the operator a human-readable line without the leak:
.with_metadata("message", serde_json::json!("Prebid returned non-success status"))| }; | ||
| // Full per-provider dump so the operator can see exactly what each SSP | ||
| // returned — `status` (nobid vs error vs success), every `bids` entry, and | ||
| // `metadata` — without needing log access. |
There was a problem hiding this comment.
⛏ nitpick — .map(neutralise_comment_terminators) applies only to the Ok branch, so the unwrap_or_else fallback string on the next line bypasses the neutraliser entirely.
Unreachable in practice (serde_json won't fail on these types), so this is not a live bug — but it's an un-neutralised seam in a path whose entire safety property is "nothing reaches the DOM un-neutralised." Applying the neutraliser after the fallback instead of inside the Ok branch removes the seam and the need to reason about it:
let providers_dump = neutralise_comment_terminators(
serde_json::to_string_pretty(&result.provider_responses)
.unwrap_or_else(|e| format!("<provider_responses serialize error: {e}>")),
);
Summary
ts-debugHTML comment (ssp/mediator/winning/time), so an operator seeingwinning=0could not tell whether prebid returned nothing, errored, or bid below the floor.provider_responses(andmediator_response) into the comment so the SSAT surfaces the same prebid server response detail an operator gets from the/auctionendpoint — status, every bid, andmetadata(which carries PBSext.errors/ext.debug.httpcallswhen prebiddebug=true).Changes
crates/trusted-server-core/src/publisher.rsprepend_auction_debug_commentnow serializesprovider_responses+mediator_responseas JSON into thets-debugcomment; neutralizes-->/--!>in the payload; adds a unit test asserting status is surfaced and terminators are neutralized (exactly one closing-->).Closes
Closes #889
Test plan
cargo fmt --all -- --checkcargo test-fastly(new testauction_debug_comment_dumps_provider_status_and_neutralises_terminatorspasses;cargo check-fastlyclean)cargo test-axum— deferred to CIcargo clippy-fastly && cargo clippy-axum— deferred to CINotes
[debug].auction_html_commentflag — off by default.Bid.creative(full ad markup) inline and can be large; intended for debugging, not steady-state production.Checklist
unwrap()in production code — useexpect("should ...")logmacros (notprintln!)