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

fix(browser): resolve CDN and write key only from the tag that loaded us#1396
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.

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. 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.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. See the second commit, which fixes exactly that bypass.

Compatibility

  • Proxy / self-hosted CDNs keep working. 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.
  • One behavior change: when the SDK is loaded by a bundler alongside a snippet served from a non-Segment CDN, that CDN is no longer auto-detected. Those setups should pass cdnURL explicitly. 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.
  • 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: 0d0c774

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 Patch

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 90.05525% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.74%. Comparing base (6567607) to head (3de3170).
⚠️ Report is 33 commits behind head on master.

⚠️ Current head 3de3170 differs from pull request most recent head 0d0c774

Please upload reports for the commit 0d0c774 to get more accurate results.

Files with missing lines Patch % Lines
packages/node/src/plugins/segmentio/publisher.ts 89.18% 16 Missing ⚠️
packages/core/src/priority-queue/index.ts 71.42% 2 Missing ⚠️
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     
Flag Coverage Δ
browser ?
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.

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
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
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