fix(browser): resolve CDN and write key only from the tag that loaded us - #1396
Open
didiergarcia wants to merge 1 commit into
Open
fix(browser): resolve CDN and write key only from the tag that loaded us#1396didiergarcia wants to merge 1 commit into
didiergarcia wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 0d0c774 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is Please upload reports for the commit 0d0c774 to get more accurate results.
Additional details and impacted files@@ Coverage Diff @@
## master #1396 +/- ##
==========================================
- Coverage 91.23% 89.74% -1.50%
==========================================
Files 163 46 -117
Lines 4393 1375 -3018
Branches 1055 322 -733
==========================================
- Hits 4008 1234 -2774
+ Misses 385 141 -244
☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
SECOPS-25767 / LIBRARIES-3140. getCDNUrlFromScriptTag(), getLegacyAJSPath() and getWriteKey() each scanned every <script> in the document and accepted a match on src shape alone (/analytics\.js\/v1\/.../), with no proof the tag had loaded the SDK and no origin check. The shape is attacker-controlled, so given the ability to inject markup into the page - but not to execute script - an attacker could add https://evil.example/analytics.js/v1/<writeKey>/analytics.min.js and redirect the settings fetch, and transitively remote-plugin script loading, to their own origin. That escalates HTML injection to script execution. The tag did not need to execute: one inserted via innerHTML is never run by the browser, but it is in the DOM and was still read. CDN resolution is now, in order: 1. an explicit cdnURL / window.analytics._cdn 2. the tag that actually loaded the SDK (document.currentScript) 3. a tag whose derived CDN base is exactly one of our own origins (https://cdn.segment.com, https://cdn.segment.build) 4. the default https://cdn.segment.com The write key is read from that same trusted src, after the existing embedded write key and window.analytics._writeKey. It no longer falls back to scanning the DOM, so a page with no trusted tag now yields no write key rather than a sniffed one. document.currentScript is only valid during the synchronous top-level run of the loading script, so it is snapshotted at boot via captureInitialScriptSrc() in the UMD and standalone entrypoints. Later callers read the snapshot, which keeps the CSP-fallback handler, the old-browser polyfill onload path and deferred .load() working - all of them run where currentScript is null. (3) is a weaker guarantee than (2) - it proves the origin is ours, not that the tag loaded us - and exists only for the bundler and tag-manager cases. It compares the full derived base rather than just the host, because the base comes from the regex's greedy prefix capture: matching on host alone would accept any path under an allowlisted origin, so a self-serve mirror such as cdn.jsdelivr.net could serve cdn.jsdelivr.net/npm/<pkg>/analytics.js/v1/<key>/analytics.min.js and control the settings we fetch. Real jsDelivr URLs for this SDK do not match the regex, so it is not on the allowlist. Proxy and self-hosted CDN setups are preserved: the proxy tag is the tag that loads the SDK, so it is trusted via (2), including first-party CDNs on a different registrable domain than the page. Setups where the SDK is loaded by a bundler alongside a snippet served from a non-Segment CDN no longer auto-detect that CDN and must pass cdnURL explicitly. Tests now model document.currentScript at boot; the previous fixtures did not.
didiergarcia
force-pushed
the
secops-25767-cdn-hardening
branch
from
September 4, 2026 19:45
d3dde33 to
0d0c774
Compare
abueide
approved these changes
Sep 4, 2026
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.
Fixes the DOM script-sniffing issue reported in SECOPS-25767 / LIBRARIES-3140.
Problem
getCDNUrlFromScriptTag(),getLegacyAJSPath()andgetWriteKey()each scanned every<script>in the document and accepted a match on URL shape alone (/analytics.js/v1/...), with no proof the tag had loaded the SDK and no origin check.The shape is attacker-controlled. So given the ability to inject markup into the page — but not to execute script — an attacker could add
https://evil.example/analytics.js/v1/<writeKey>/analytics.min.jsand redirect the settings fetch, and transitively remote-plugin script loading, to their own origin. That escalates HTML injection to script execution.The injected tag never had to execute: a
<script>inserted viainnerHTMLis never run by the browser, but it is still in the DOM and was still read.Fix
CDN resolution is now, in order:
cdnURL/window.analytics._cdndocument.currentScript)https://cdn.segment.com,https://cdn.segment.build)https://cdn.segment.comThe write key is read from that same trusted source, after the existing embedded write key and
window.analytics._writeKey. It no longer falls back to scanning the DOM, so a page with no trusted tag yields no write key rather than a sniffed one.document.currentScriptis only valid during the synchronous top-level run of the loading script, so it is snapshotted at boot viacaptureInitialScriptSrc()in the UMD and standalone entrypoints. Later callers read the snapshot, which keeps the CSP-fallback handler, the old-browser polyfillonloadpath and deferred.load()working — all of them run wherecurrentScriptisnull.Note (3) is a weaker guarantee than (2) — it proves the origin is ours, not that the tag loaded us — and exists only for the bundler and tag-manager cases. It compares the full derived base, not just the host, because the base comes from the regex's greedy prefix capture: matching on host alone would accept any path under an allowlisted origin. See the second commit, which fixes exactly that bypass.
Compatibility
cdnURLexplicitly. Fallback is always the default Segment CDN, never an untrusted origin.Testing
parse-cdn.test.ts: 14 tests covering proxy-preserved, injected-tag-ignored, allowlist fallback, hostname-suffix confusion (cdn.segment.com.evil.example.com), greedy-prefix path abuse, and self-serve-mirror rejection.tscand eslint clean.document.currentScriptat boot — what the browser actually does, and what the previous fixtures did not represent.