Skip to content

Harden the Google Tag Manager proxy's upstream and tag selection - #1109

Draft
prk-Jr wants to merge 9 commits into
mainfrom
fix/gtm-upstream-tag-id
Draft

Harden the Google Tag Manager proxy's upstream and tag selection#1109
prk-Jr wants to merge 9 commits into
mainfrom
fix/gtm-upstream-tag-id

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The GTM proxy decided which tag to fetch from the client's query string rather than from configuration, so the tag this origin re-served as first-party JavaScript was the caller's choice, not the publisher's.
  • The upstream fetch was unpinned: no scheme requirement and an empty domain allowlist, which is_host_permitted treats as "any host", while redirects are followed by default.
  • Responses passed through unmodified could carry upstream headers that act against the serving origin.

Operator note — read before upgrading

If your pages load gtag/js with a measurement id different from your container, add those ids to the new allowed_tag_ids.

[integrations.google_tag_manager]
container_id = "GTM-XXXXXX"
allowed_tag_ids = ["G-XXXXXXXX", "AW-000000"]

Nothing breaks if you miss one — the tag is answered with a redirect to Google and still loads for the visitor. But it is then served third-party, so it loses the ad-blocker resilience this integration exists to provide, and a <link rel="preload"> for it pays an extra round trip. Find the ids your pages use:

curl -s https://www.example.com/ | grep -oE 'gtag/js\?id=[A-Za-z0-9-]+'

This was confirmed against a live deployment: its pages preload two GA4 ids distinct from the container, and both fell back to the redirect until they were listed.

Behavior

Request Before After
gtm.js?id=<anything> fetches the id the client named fetches container_id; other params (l, gtm_auth, gtm_preview, gtm_cookies_win) preserved
gtag/js?id=<configured> proxied proxied, query rebuilt canonically
gtag/js?id=<other> proxied 302 to upstream, private, no-store
gtag/js?ID=<a>&id=<b> forwarded verbatim single validated id sent
/collect, /g/collect passed through passed through, minus Set-Cookie / Strict-Transport-Security / Clear-Site-Data
script-body URL rewriting any path on the Google hosts only the five routed paths

Tag ids are matched exactly; a different capitalization is treated as unconfigured and redirected. gtag/js answers 200 for ids that do not exist, so upstream cannot be relied on to reject an unknown tag.

Changes

File Change
crates/trusted-server-core/src/integrations/google_tag_manager.rs build_target_url returns a GtmTarget (Proxy/Redirect); canonical_query rebuilds the query so the validated id is the one sent; tag_id_is_allowed gates gtag/js; redirect_to_upstream serves the non-matching case
New allowed_tag_ids config plus validate_allowed_tag_ids; validate_https_upstream requires an https URL with a literal (non-wildcard) host
build_proxy_config adds .with_https_only() and .with_allowed_domains(...), pinned to the configured upstream and the GA beacon host
strip_upstream_first_party_headers applied before any branch, so it covers passthrough and the upstream-error early return
GTM_URL_PATTERN narrowed to the routed paths with a terminator guard; host literals consolidated onto constants
handle split into empty_response, content_length_rejection, payload_size_status, rewritten_script_response
crates/trusted-server-core/src/proxy.rs ASSET_PROXY_STRIP_RESPONSE_HEADERS renamed to pub(crate) FIRST_PARTY_PASSTHROUGH_STRIP_HEADERS and shared, so the two passthrough paths cannot drift
docs/guide/integrations/google_tag_manager.md Documents allowed_tag_ids, the https requirement, exact matching, the redirect behavior, and an upgrade note
trusted-server.example.toml Adds allowed_tag_ids with guidance

34 tests added, covering id clamping and parameter pollution (duplicate, case-varied and percent-encoded keys), the allowlist and exact matching, the redirect, host pinning, header stripping through handle, the rewriter's routed-path and terminator behavior, and the config validators.

Closes

Closes #1115

Test plan

  • cargo test-fastly && cargo test-axum (also test-cloudflare, test-spin)
  • cargo clippy-fastly && cargo clippy-axum (also clippy-cloudflare, clippy-cloudflare-wasm, clippy-spin-native, clippy-spin-wasm, trusted-server-cli)
  • cargo fmt --all -- --check
  • JS tests and format
  • Docs format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Cross-adapter parity suite: 13 passed
  • Manual testing via fastly compute serve — verified id clamping, the allowlist before and after configuring real tag ids, exact matching, all three parameter-pollution shapes, the redirect chain through headless Chrome, and beacon passthrough

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code
  • Uses log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

Serve only the configured container from gtm.js: a client-supplied id is
dropped and the query is rebuilt, so the id the upstream parses is the one
this code validated rather than whatever the client sent alongside it.

Accept a gtag/js tag id only when it matches container_id or the new
allowed_tag_ids, and answer any other id with a redirect to the upstream so
the tag still loads for the visitor without this origin serving it. Upstream
returns 200 for tag ids that do not exist, so it cannot be relied on to
reject an unknown tag.

Pin upstream fetches to https and to the configured hosts so a redirect
cannot carry them somewhere else, and drop Set-Cookie,
Strict-Transport-Security and Clear-Site-Data from responses that are passed
through and served from the publisher's origin.

Narrow the URL rewriter to the paths this integration routes, matching the
allowlist the attribute rewriter already applied.
…tors

Reject an upstream_url that embeds a username or password. The upstream is
handed to the browser in the redirect served for an unconfigured tag, so
anything embedded in it is disclosed to the client.

Match the end of a rewritable URL by requiring that the next byte is not a
path character, rather than by naming the terminators. The named set missed
the ones nobody thought of: a routed path inside a template literal, ended by
a backtick or an interpolation, stopped being rewritten.
Treating anything that is not a path character as the end of the URL still
rewrote a longer path that merely starts with a routed one, because a path may
continue with a percent-encoded byte or with `:` and `@`. `/collect%58YZ`
became a first-party URL with no route behind it.

Match on the delimiters that end a URL in the surrounding source instead. The
extent of a URL here is set by the language it is embedded in, not by the RFC
path grammar, so characters that are legal in a path can still end it. Listing
the terminators also fails safe: an unrecognised byte leaves the URL
third-party rather than pointing it at a route that does not exist.
A character class cannot tell a delimiter of the surrounding source from a
character inside the URL: `;`, `,`, `&`, `$`, `'` and parentheses are all
legal in a path. Treating them as terminators rewrote `/collect;matrix` as
though it were the routed `/collect`, pointing it at a first-party URL with
no route behind it.

Anchor the match on the quote that opens the URL instead, and require the
matching close, so the path must genuinely end where the routed path ends. A
template literal may also be closed by an interpolation. An attribute value
carries no surrounding source, so it is matched as a whole string.

One consequence is that a fragment can no longer be rewritten on its own,
since the delimiter that bounds the URL may be in the next chunk. Text is
already reassembled before rewriting, so this only shows up when a chunk
boundary falls inside the `google` prefix shared by both markers, which is
the trade-off GTM_MIN_PREFIX_LEN already documents. Both outcomes now have a
test.
An unlisted tag is not merely served third-party. A redirect does not make
the upstream origin satisfy script-src 'self', so a strict policy that does
not list googletagmanager.com refuses the redirected script and the tag does
not run. Standard gtag installations use a product tag id distinct from the
container, so most deployments loading gtag/js have one to add.
is_rewritable_url accepts a fragment, but the delimiter-anchored patterns
allowed only a query, so a routed URL ending in `#...` was left third-party.
A suffix may now begin with either `?` or `#` and is preserved verbatim.

Drop the remaining claims that a redirected tag still loads for the visitor.
They contradicted the upgrade note: a redirect does not make the upstream
origin satisfy script-src 'self', so a policy that does not list it refuses
the script. Name the configured upstream as the origin to allow rather than
the default host, since a deployment may point upstream_url elsewhere.

Point the rewriter's documentation at the patterns that replaced
GTM_URL_PATTERN, which cargo doc reported as an unresolved link.
The template offers an upstream_url override four lines above, so telling an
operator to allowlist googletagmanager.com is wrong for anyone who takes it.
The paragraph above already states the rule, so the duplicate claim goes
rather than being repeated correctly.
@prk-Jr

prk-Jr commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closes #1115

gtm.js substituted container_id for whatever the request named, so a page
loading a second configured container silently got the first one and its tags
never fired. Both script paths now share one decision: serve a configured tag
as asked for, refuse anything else, and fall back to container_id when the
request names no tag at all — upstream answers 200 for anything, so a request
without a tag cannot be forwarded as-is.

Require upstream_url to be a bare origin. Targets are built by appending to
it, so `https://host#f` appended to the fragment and fetched the upstream
homepage, which was then re-served as script.

Allow the analytics host and its regional endpoints in the proxy pin, since
the rewriter points at them and a redirect there was otherwise refused.

Rewrite an origin with no path again. A script may hold the origin and
concatenate the path later, and leaving it alone sent the beacon direct.
@aram356 aram356 added this to the 202609 milestone Sep 2, 2026
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.

GTM proxy resolves the tag from the request rather than from configuration

2 participants