Skip to content

Prebid attribute rewriter runs before js_asset_proxy and drops enabled assets #1205

Description

@aram356

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_proxy at the top of the builder table, ahead of aps and prebid, and IntegrationRegistry::new built only from that table. During review, a maintainer pointed out that "rewrite_attribute chains Replace results and short-circuits on RemoveElement, 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 aps and prebid from the table and added with_plan, which pushes their registrations first. Its review comments do not mention js_asset_proxy or 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 and js_asset_proxy match, 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:293 and 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 generate drafts a proxy = "disabled" entry with # Native integration may be preferable: [integrations.prebid] under # Generated by `ts audit`; review before enabling. Switching that entry to enabled silently does nothing.
  • Precedence that depends on registration position can move again. If a native rewriter that replaces URLs lands ahead of js_asset_proxy the same way, blocked entries 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_script

Observed 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_patterns match, blocked removes the element and disabled lets Prebid remove it.
  • While [integrations.prebid] is enabled, config validation rejects an enabled asset for such a URL. The error names both tables.

Actual behavior

  • Prebid's attribute rewriter runs before js_asset_proxy.
  • An enabled asset 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 a js_asset_proxy asset set to proxy = "enabled" whose origin_url path matches Prebid's script_patterns. ts audit generate leads operators here. For a third-party HTTPS script whose host or path contains prebid (crates/trusted-server-cli/src/commands/audit/generate/analyzer.rs:204-205), it writes # Detected integration: prebid, # Native integration may be preferable: [integrations.prebid] and proxy = "disabled" (audit/generate/mod.rs:574-593 in the same directory).
  • What operators see: the publisher's Prebid.js tag disappears, and the page runs the managed bundle instead. The js_asset_proxy entry has no effect, and no warning appears. The page keeps working, so the severity today is low.
  • Latent risk: protection for blocked entries now depends on registration position. If a native rewriter that replaces URLs ends up ahead of js_asset_proxy, a blocked script 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, so blocked still 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

  1. 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:293 to point to this line. Registering js_asset_proxy before prebid::register_for_plan would also work today, but a later change could undo it the same way Add configuration-driven OpenRTB auction providers #1016 did.

  2. Reject the contradictory combination. While [integrations.prebid] is enabled, reject an enabled asset whose origin_url Prebid'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 both validate_settings_for_deploy (config.rs:247-260) and validate_settings_for_runtime (config.rs:268-278). It needs a crate-visible helper that applies Prebid's matches_script_url logic to a PrebidIntegrationConfig.

  3. Add tests. Add the three tests above, plus a test that js_asset_proxy is 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: enabled is rewritten to its first-party path, blocked is removed, and disabled is removed by Prebid.
  • Config validation rejects an enabled asset matched by the enabled Prebid integration's script_patterns. Tests cover the error and the accepted disabled and blocked variants.
  • The comment at integrations/mod.rs:293 states where the precedence is enforced.
  • The existing GPT precedence tests still pass.

Affected area

Integrations (prebid, lockr, permutive, etc.)

Version

main at a4e01eb

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions