Skip to content

fix(browser): resolve CDN and write key only from the tag that loaded us - #1396

Open
didiergarcia wants to merge 1 commit into
masterfrom
secops-25767-cdn-hardening
Open

didiergarcia wants to merge 1 commit into
masterfrom
secops-25767-cdn-hardening

Conversation

@didiergarcia

@didiergarcia didiergarcia commented Sep 1, 2026

Copy link
Copy Markdown

Fixes the DOM script-sniffing issue reported in SECOPS-25767 / LIBRARIES-3140.

Behavior changes

The SDK no longer discovers its CDN or write key by scanning arbitrary <script> tags in the page. Two configurations that previously worked by auto-detection now need explicit configuration:

  1. SDK loaded by a bundler alongside a snippet on a non-Segment CDN. The snippet's CDN is no longer auto-detected; resolution falls back to https://cdn.segment.com. Pass cdnURL explicitly: AnalyticsBrowser.load({ writeKey, cdnURL: 'https://your.cdn' }). If your CSP allowlists only your own CDN, the settings request to cdn.segment.com will be blocked and analytics will fail to initialize — this does not degrade silently.
  2. Pages relying on the write key being sniffed out of the DOM. getWriteKey() no longer scans; it reads the embedded write key, window.analytics._writeKey, then the tag that loaded the SDK. A page with none of those now yields no write key and analytics will not initialize. Snippet versions 4.15.3 and later set _writeKey and are unaffected.

Unaffected: standard snippet installations, and proxy or self-hosted setups where the SDK is loaded from the proxy's own tag — including first-party CDNs on a different registrable domain than the page.

Problem

getCDNUrlFromScriptTag(), getLegacyAJSPath() and getWriteKey() 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.js and 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 via innerHTML is never run by the browser, but it is still in the DOM and was still read.

Fix

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 source, after the existing embedded write key and window.analytics._writeKey.

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.

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, which would let a self-serve mirror serve attacker-published content.

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.
  • Full browser suite passing; tsc and eslint clean.
  • Existing CSP / standalone / write-key tests updated to model document.currentScript at boot — what the browser actually does, and what the previous fixtures did not represent.

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e9cffa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@segment/analytics-next Minor

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

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.97872% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.67%. Comparing base (6567607) to head (effbeb6).
⚠️ Report is 37 commits behind head on master.

⚠️ Current head effbeb6 differs from pull request most recent head 5e9cffa

Please upload reports for the commit 5e9cffa to get more accurate results.

Files with missing lines Patch % Lines
packages/node/src/plugins/segmentio/publisher.ts 89.18% 16 Missing ⚠️
...rowser/src/plugins/segmentio/batched-dispatcher.ts 95.32% 5 Missing ⚠️
packages/browser/src/browser/index.ts 0.00% 3 Missing ⚠️
packages/browser/src/browser/browser-umd.ts 0.00% 2 Missing ⚠️
...kages/browser/src/core/storage/universalStorage.ts 88.23% 2 Missing ⚠️
packages/browser/src/plugins/segmentio/index.ts 92.59% 2 Missing ⚠️
packages/core/src/priority-queue/index.ts 71.42% 2 Missing ⚠️
...ckages/browser/src/browser/standalone-analytics.ts 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1396      +/-   ##
==========================================
+ Coverage   91.23%   91.67%   +0.44%     
==========================================
  Files         163      127      -36     
  Lines        4393     4169     -224     
  Branches     1055     1044      -11     
==========================================
- Hits         4008     3822     -186     
+ Misses        385      347      -38     
Flag Coverage Δ
browser 92.62% <94.80%> (+0.41%) ⬆️
core 90.07% <71.42%> (-0.10%) ⬇️
node 89.43% <90.80%> (+1.50%) ⬆️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@didiergarcia
didiergarcia force-pushed the secops-25767-cdn-hardening branch from d3dde33 to 0d0c774 Compare September 4, 2026 19:45
@didiergarcia didiergarcia changed the title fix(browser): resolve CDN and write key only from trusted script tags fix(browser): resolve CDN and write key only from the tag that loaded us Sep 4, 2026
abueide
abueide previously approved these changes Sep 4, 2026
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.
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.

2 participants