Harden the Google Tag Manager proxy's upstream and tag selection - #1109
Draft
prk-Jr wants to merge 9 commits into
Draft
Harden the Google Tag Manager proxy's upstream and tag selection#1109prk-Jr wants to merge 9 commits into
prk-Jr wants to merge 9 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
is_host_permittedtreats as "any host", while redirects are followed by default.Operator note — read before upgrading
If your pages load
gtag/jswith a measurement id different from your container, add those ids to the newallowed_tag_ids.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: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
gtm.js?id=<anything>container_id; other params (l,gtm_auth,gtm_preview,gtm_cookies_win) preservedgtag/js?id=<configured>gtag/js?id=<other>private, no-storegtag/js?ID=<a>&id=<b>/collect,/g/collectSet-Cookie/Strict-Transport-Security/Clear-Site-DataTag ids are matched exactly; a different capitalization is treated as unconfigured and redirected.
gtag/jsanswers200for ids that do not exist, so upstream cannot be relied on to reject an unknown tag.Changes
crates/trusted-server-core/src/integrations/google_tag_manager.rsbuild_target_urlreturns aGtmTarget(Proxy/Redirect);canonical_queryrebuilds the query so the validated id is the one sent;tag_id_is_allowedgatesgtag/js;redirect_to_upstreamserves the non-matching caseallowed_tag_idsconfig plusvalidate_allowed_tag_ids;validate_https_upstreamrequires an https URL with a literal (non-wildcard) hostbuild_proxy_configadds.with_https_only()and.with_allowed_domains(...), pinned to the configured upstream and the GA beacon hoststrip_upstream_first_party_headersapplied before any branch, so it covers passthrough and the upstream-error early returnGTM_URL_PATTERNnarrowed to the routed paths with a terminator guard; host literals consolidated onto constantshandlesplit intoempty_response,content_length_rejection,payload_size_status,rewritten_script_responsecrates/trusted-server-core/src/proxy.rsASSET_PROXY_STRIP_RESPONSE_HEADERSrenamed topub(crate) FIRST_PARTY_PASSTHROUGH_STRIP_HEADERSand shared, so the two passthrough paths cannot driftdocs/guide/integrations/google_tag_manager.mdallowed_tag_ids, the https requirement, exact matching, the redirect behavior, and an upgrade notetrusted-server.example.tomlallowed_tag_idswith guidance34 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(alsotest-cloudflare,test-spin)cargo clippy-fastly && cargo clippy-axum(alsoclippy-cloudflare,clippy-cloudflare-wasm,clippy-spin-native,clippy-spin-wasm,trusted-server-cli)cargo fmt --all -- --checkcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly 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 passthroughChecklist
unwrap()in production codelogmacros (notprintln!)