Repository navigation
Prebid attribute rewriter runs before js_asset_proxy and drops enabled assets #1205
Description
Description
js_asset_proxy is supposed to see every <script src> before any native integration's attribute rewriter. The builder table says so (crates/trusted-server-core/src/integrations/mod.rs:293):
// This must remain first: attribute rewriters chain replacements and short-circuit removals.That is no longer true at runtime. IntegrationRegistry::with_plan registers Prebid and APS from the auction plan before it walks the builder table (integrations/registry.rs:815-831), and attribute rewriters run in registration order. Prebid's rewriter therefore runs first. APS registers no attribute rewriter, so Prebid is the only native rewriter ahead of js_asset_proxy today. With Prebid, js_asset_proxy and GPT enabled, the effective order is ["prebid", "js_asset_proxy", "gpt"].
As a result, an operator's [[integrations.js_asset_proxy.assets]] entry with proxy = "enabled" for a publisher Prebid.js URL never takes effect. This applies to any URL whose path matches Prebid's script_patterns, such as one ending in /prebid.min.js. Prebid removes the element before js_asset_proxy can rewrite it. The asset's first-party route is still registered, and no validation error, startup error or log line says the entry is dead. blocked and disabled entries give the same result in either order.
How it happened:
- Add JavaScript asset proxy integration #742 (commit 22fbbf2, merged 2026-09-04) put
js_asset_proxyat the top of the builder table, ahead ofapsandprebid, andIntegrationRegistry::newbuilt only from that table. During review, a maintainer pointed out that "rewrite_attributechainsReplaceresults and short-circuits onRemoveElement, so js_asset_proxy's precedence over native rewriters (GPT etc.) exists only because it is first in this list". That thread produced the comment above. The precedence tests already existed. - Add configuration-driven OpenRTB auction providers #1016 (commit dbc1012, merged 2026-09-08) removed
apsandprebidfrom the table and addedwith_plan, which pushes their registrations first. Its review comments do not mentionjs_asset_proxyor rewriter order. - The precedence tests from Add JavaScript asset proxy integration #742 (
js_asset_proxy.rs:845-947) use GPT URLs only. The shared test settings enable Prebid (test_support.rs:24-29), but no test uses a URL that both Prebid andjs_asset_proxymatch, so CI did not catch the change.
Why this is a bug, and which precedence is right. Prebid removes publisher Prebid.js on purpose. Trusted Server always injects its managed /integrations/prebid/bundle.js, and the interception "prevents duplicate Prebid instances" (docs/guide/integrations/prebid.md:415). For this one URL, today's outcome is the safe one: the publisher script is removed and the managed bundle stays. The ordering is still wrong, for four reasons:
- The rule at
mod.rs:293and in the Add JavaScript asset proxy integration #742 review is that the operator's per-URL decision comes first. Add configuration-driven OpenRTB auction providers #1016 changed that without discussion, only because of where registrations are pushed. - An asset entry names one exact URL, while Prebid's patterns are suffix defaults. An explicit operator decision should not be discarded silently by a default.
- The CLI offers this exact choice. For a third-party script whose host or path contains
prebid,ts audit generatedrafts aproxy = "disabled"entry with# Native integration may be preferable: [integrations.prebid]under# Generated by `ts audit`; review before enabling.Switching that entry toenabledsilently does nothing. - Precedence that depends on registration position can move again. If a native rewriter that replaces URLs lands ahead of
js_asset_proxythe same way,blockedentries stop working (see the probe below).
So the right precedence is js_asset_proxy first for every attribute rewriter, enforced in code instead of by list position. Prebid's removal protects the single-instance invariant, so an enabled asset that Prebid would remove is contradictory configuration: honoring it loads the publisher's Prebid.js next to the managed bundle (verified below). Validation should reject that combination instead of either order resolving it silently.
Steps to reproduce
Add these tests to mod tests in crates/trusted-server-core/src/integrations/js_asset_proxy.rs. The module already provides create_test_settings, json!, IntegrationRegistry and process_html_with_registry, and create_test_settings enables [integrations.prebid] with the default script_patterns.
fn process_prebid_script_with_asset_mode(mode: &str) -> String {
// `create_test_settings` enables `[integrations.prebid]` with the default
// `script_patterns`, so its attribute rewriter matches `/prebid.min.js`.
let mut settings = create_test_settings();
settings
.integrations
.insert_config(
JS_ASSET_PROXY_INTEGRATION_ID,
&json!({
"enabled": true,
"assets": [{
"path": "/assets/vendor-pb.js",
"origin_url": "https://cdn.example.com/prebid.min.js",
"proxy": mode
}]
}),
)
.expect("should insert JS asset proxy config");
let registry = IntegrationRegistry::new(&settings).expect("should build registry");
let html = r#"<html><body><script src="https://cdn.example.com/prebid.min.js"></script></body></html>"#;
process_html_with_registry(html, registry)
}
#[test]
fn js_asset_proxy_rewriter_takes_precedence_over_prebid_script_removal() {
let processed = process_prebid_script_with_asset_mode("enabled");
assert!(
processed.contains(r#"<script src="/assets/vendor-pb.js"></script>"#),
"JS asset proxy should rewrite before Prebid removes the script: {processed}"
);
}
#[test]
fn blocked_js_asset_proxy_entry_removes_prebid_script() {
let processed = process_prebid_script_with_asset_mode("blocked");
assert!(
!processed.contains("prebid.min.js") && !processed.contains("<script"),
"blocked JS asset should remove the script element: {processed}"
);
}
#[test]
fn disabled_js_asset_proxy_entry_lets_prebid_remove_script() {
let processed = process_prebid_script_with_asset_mode("disabled");
assert!(
!processed.contains("prebid.min.js") && !processed.contains("<script"),
"disabled JS asset should defer to Prebid script interception: {processed}"
);
}Run them on the host target:
cargo test -p trusted-server-core --target <host-triple> --lib -- \
prebid_script_removal removes_prebid_script lets_prebid_remove_scriptObserved at a4e01eb:
test integrations::js_asset_proxy::tests::blocked_js_asset_proxy_entry_removes_prebid_script ... ok
test integrations::js_asset_proxy::tests::disabled_js_asset_proxy_entry_lets_prebid_remove_script ... ok
test integrations::js_asset_proxy::tests::js_asset_proxy_rewriter_takes_precedence_over_prebid_script_removal ... FAILED
JS asset proxy should rewrite before Prebid removes the script: <html><body></body></html>
(The filter also runs two existing Prebid tests, which pass.)
I also ran a probe in a scratch copy of a4e01eb. The copy has a test-only hook that can re-sort registrations to show what each order does. For the same enabled asset, rewrite_attribute("src", "https://cdn.example.com/prebid.min.js", ...) returned:
| Setup | Attribute rewriter order | Result |
|---|---|---|
| Today | ["prebid", "js_asset_proxy"] |
RemoveElement |
js_asset_proxy re-sorted first |
["js_asset_proxy", "prebid"] |
Replaced("/assets/vendor-pb.js") |
Today, with Prebid script_patterns = [] |
["prebid", "js_asset_proxy"] |
Replaced("/assets/vendor-pb.js") |
Through publisher::stream_publisher_body, today's page keeps only the managed <script src="/integrations/prebid/bundle.js" defer></script> in <head>. With js_asset_proxy first, the page has that tag and <script src="/assets/vendor-pb.js"></script>, which means two Prebid.js loads.
Hypothetical blocked bypass. With a blocked asset for https://securepubads.g.doubleclick.net/tag/js/gpt.js and GPT enabled, today's order ["prebid", "js_asset_proxy", "gpt"] gives RemoveElement. With ["prebid", "gpt", "js_asset_proxy"] it gives Replaced("/integrations/gpt/script").
Expected behavior
js_asset_proxy's attribute rewriter runs before every other attribute rewriter, whatever order integrations register in.- For a URL that Prebid's
script_patternsmatch,blockedremoves the element anddisabledlets Prebid remove it. - While
[integrations.prebid]is enabled, config validation rejects anenabledasset for such a URL. The error names both tables.
Actual behavior
- Prebid's attribute rewriter runs before
js_asset_proxy. - An
enabledasset for a publisher Prebid.js URL is removed from the page instead of being rewritten to its first-party path. - Its route is registered but unused, and nothing reports the conflict.
Root cause
with_plan pushes the plan registrations before the builder loop (integrations/registry.rs:815-831):
if let Some(registration) = crate::integrations::prebid::register_for_plan(settings, &plan)?
{
registrations.push(registration);
}
if let Some(registration) = crate::integrations::aps::register_for_plan(settings, &plan)? {
registrations.push(registration);
}
for builder in crate::integrations::builders() {Attribute rewriters are appended in that order (registry.rs:886-888). rewrite_attribute threads replacements through them and stops at the first removal (registry.rs:1045-1058):
AttributeRewriteAction::RemoveElement => {
return AttributeRewriteOutcome::RemoveElement;
}Prebid's register_for_plan includes its attribute rewriter (prebid.rs:1372-1379). That rewriter returns remove_element() when matches_script_url matches (prebid.rs:1459-1470). matches_script_url drops the query and fragment and checks the URL path against script_patterns (prebid.rs:1024-1051). The defaults are /prebid.js, /prebid.min.js, /prebidjs.js and /prebidjs.min.js (prebid.rs:757-762). The js_asset_proxy branch JsAssetProxyMode::Enabled => AttributeRewriteAction::replace(asset.path.clone()) (js_asset_proxy.rs:560) never runs for these URLs.
The only thing that enforces the rule at mod.rs:293 is the entry's position inside builders(). #1016 added registrations in front of that list.
Impact
- Affected deployments: those with
[integrations.prebid]enabled and ajs_asset_proxyasset set toproxy = "enabled"whoseorigin_urlpath matches Prebid'sscript_patterns.ts audit generateleads operators here. For a third-party HTTPS script whose host or path containsprebid(crates/trusted-server-cli/src/commands/audit/generate/analyzer.rs:204-205), it writes# Detected integration: prebid,# Native integration may be preferable: [integrations.prebid]andproxy = "disabled"(audit/generate/mod.rs:574-593in the same directory). - What operators see: the publisher's Prebid.js tag disappears, and the page runs the managed bundle instead. The
js_asset_proxyentry has no effect, and no warning appears. The page keeps working, so the severity today is low. - Latent risk: protection for
blockedentries now depends on registration position. If a native rewriter that replaces URLs ends up ahead ofjs_asset_proxy, ablockedscript is served from the publisher's origin through that integration's proxy instead of being removed (see the GPT probe above). Only Prebid, which removes, is ahead today, soblockedstill holds. The open ordering spec in Define integrations crate split and ordered configuration #1194 would make hook order operator-configurable, which raises this risk.
Proposed fix
-
Enforce the precedence in
with_plan. Do it after all registrations are applied, so it does not depend on push order:// js_asset_proxy is the operator's per-URL override, so it runs before every // native attribute rewriter. The sort is stable for the others. inner.html_rewriters.sort_by_key(|rewriter| { rewriter.integration_id() != crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID });
Update the comment at
mod.rs:293to point to this line. Registeringjs_asset_proxybeforeprebid::register_for_planwould also work today, but a later change could undo it the same way Add configuration-driven OpenRTB auction providers #1016 did. -
Reject the contradictory combination. While
[integrations.prebid]is enabled, reject anenabledasset whoseorigin_urlPrebid's interception would remove. The error should name both tables and the ways out:proxy = "disabled"keeps today's page behavior, with Prebid intercepting the script.proxy = "blocked".- Removing the pattern from
script_patterns, which the Prebid guide calls "not recommended; may duplicate the managed bundle".
Put the check next to
validate_js_asset_proxy_config(config.rs:280-299), which runs in bothvalidate_settings_for_deploy(config.rs:247-260) andvalidate_settings_for_runtime(config.rs:268-278). It needs a crate-visible helper that applies Prebid'smatches_script_urllogic to aPrebidIntegrationConfig. -
Add tests. Add the three tests above, plus a test that
js_asset_proxyis the first attribute rewriter when Prebid and APS plan registrations are present.
I prototyped step 1 in a scratch copy of a4e01eb. With the sort, js_asset_proxy_rewriter_takes_precedence_over_prebid_script_removal passes, the blocked and disabled tests still pass, and every existing test in the native cargo test -p trusted-server-core --lib run passes, including the GPT precedence tests.
Compatibility: step 1 changes runtime behavior only for the contradictory configuration, where the publisher's Prebid.js would be proxied and load next to the managed bundle. Step 2 turns that case into a validation error. Because the check also runs at runtime, an already-deployed config with such an entry would stop starting until the entry is set to disabled or blocked. Say this in the release notes and ask operators to run ts config validate before upgrading. The check could instead run only at deploy time, with a warning at registry build, to avoid failing startup. In that case, an existing config that is never re-validated would start loading both Prebid.js copies.
Alternative, if maintainers want Prebid's interception to win on purpose: keep today's order but make it explicit. Document the exception at mod.rs:293 and in the js_asset_proxy docs, and add a test that pins Prebid ahead of js_asset_proxy. The step 2 validation is still worth adding, because an enabled entry that can never apply is a configuration error in either design. This keeps runtime behavior unchanged, but precedence stays tied to registration position.
Done when
-
js_asset_proxy's attribute rewriter runs first whenever it is registered, independent of registration order. A test asserts this with Prebid and APS plan registrations present. - The three Prebid-overlap tests above pass:
enabledis rewritten to its first-party path,blockedis removed, anddisabledis removed by Prebid. - Config validation rejects an
enabledasset matched by the enabled Prebid integration'sscript_patterns. Tests cover the error and the accepteddisabledandblockedvariants. - The comment at
integrations/mod.rs:293states where the precedence is enforced. - The existing GPT precedence tests still pass.
Affected area
Integrations (prebid, lockr, permutive, etc.)
Version
main at a4e01eb
Related
- Add JavaScript asset proxy integration #742: added
js_asset_proxyfirst in the builder table, the "must remain first" comment and the GPT-only precedence tests. - Add configuration-driven OpenRTB auction providers #1016: moved Prebid and APS registration ahead of the builder table, which put Prebid's rewriter first.
- Define integrations crate split and ordered configuration #1194 (open): integrations ordering spec. Its review found that
js_asset_proxyis not first today and that a URL-replacing rewriter placed ahead of it bypassesblockedentries.
Activity