Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/secops-25767-cdn-trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@segment/analytics-next': patch
---

Harden CDN and write-key resolution (SECOPS-25767).

Previously the SDK determined which origin to trust for its settings by
scanning every `<script>` tag in the document and accepting a match on URL
shape alone, with no proof the tag had loaded the SDK and no origin check.
Because the shape is attacker-controlled, anyone able to inject markup into the
page - but not to execute script - could add a Segment-shaped `<script src>` on
their own origin and redirect the settings fetch, and transitively remote-plugin
script loading, to that origin. 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 explicitly configured `cdnURL` / `window.analytics._cdn`
2. the tag that actually loaded the SDK (`document.currentScript`, snapshotted at
boot so it also works from the CSP fallback handler, the polyfill `onload`,
and deferred `.load()` calls)
3. a tag whose derived CDN base is exactly one of our own origins
(`https://cdn.segment.com`, `https://cdn.segment.build`)
4. otherwise 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.

Proxy and self-hosted CDN setups continue to work: 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 on a non-Segment CDN no longer auto-detect that CDN
and should set `cdnURL` explicitly.
11 changes: 11 additions & 0 deletions packages/browser/src/browser/__tests__/csp-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ describe('CSP Detection', () => {
documentSpy.mockImplementation(
() => jsd.window.document as unknown as Document
)

// SECOPS-25767: the SDK now trusts only the tag that loaded it. Model that
// by pointing document.currentScript at the analytics.min.js tag the
// snippet inserted, as the browser would during the bundle's boot.
const loaderTag = jsd.window.document.querySelector(
'script[src*="/analytics.js/v1/"]'
)
Object.defineProperty(jsd.window.document, 'currentScript', {
configurable: true,
get: () => loaderTag,
})
})

it('reverts to ajs classic in case of CSP errors', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ describe('standalone bundle', () => {
documentSpy.mockImplementation(
() => jsd.window.document as unknown as Document
)

// SECOPS-25767: the SDK now derives the write key / CDN only from the tag
// that loaded it. Point document.currentScript at the analytics.min.js tag
// the snippet inserted, as the browser would during the bundle's boot.
const loaderTag = jsd.window.document.querySelector(
'script[src*="/analytics.js/v1/"]'
)
Object.defineProperty(jsd.window.document, 'currentScript', {
configurable: true,
get: () => loaderTag,
})
})

it('detects embedded write keys', async () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/browser/src/browser/__tests__/standalone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ describe('standalone bundle', () => {
documentSpy.mockImplementation(
() => jsd.window.document as unknown as Document
)

// SECOPS-25767: the SDK now trusts only the tag that loaded it. Point
// document.currentScript at the analytics.min.js tag the snippet inserted,
// as the browser would during the bundle's boot.
const loaderTag = jsd.window.document.querySelector(
'script[src*="/analytics.js/v1/"]'
)
Object.defineProperty(jsd.window.document, 'currentScript', {
configurable: true,
get: () => loaderTag,
})
})

it('loads AJS on execution', async () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/browser/src/browser/browser-umd.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn'
import {
captureInitialScriptSrc,
getCDN,
setGlobalCDNUrl,
} from '../lib/parse-cdn'
import { setVersionType } from '../lib/version-type'

// SECOPS-25767: snapshot the loading tag's src at boot, while
// document.currentScript is still valid, before any async work runs.
captureInitialScriptSrc()

if (process.env.IS_WEBPACK_BUILD) {
if (process.env.ASSET_PATH) {
// @ts-ignore
Expand Down
33 changes: 11 additions & 22 deletions packages/browser/src/browser/standalone-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getGlobalAnalytics,
setGlobalAnalytics,
} from '../lib/global-analytics-helper'
import { getTrustedScriptSrc } from '../lib/parse-cdn'

function getWriteKey(): string | undefined {
if (embeddedWriteKey()) {
Expand All @@ -16,34 +17,22 @@ function getWriteKey(): string | undefined {
return analytics._writeKey
}

// SECOPS-25767: resolve the write key only from a trusted tag - the one that
// actually loaded us (document.currentScript, snapshotted at boot so this
// also works from polyfill onload / deferred contexts where currentScript is
// null), or failing that a tag on a known public Segment CDN. Never from an
// arbitrary <script> in the DOM: a sniffed write key could redirect a
// customer's event stream to an attacker-owned workspace.
const regex = /http.*\/analytics\.js\/v1\/([^/]*)(\/platform)?\/analytics.*/
const scripts = Array.prototype.slice.call(
document.querySelectorAll('script')
)
let writeKey: string | undefined = undefined

for (const s of scripts) {
const src = s.getAttribute('src') ?? ''
const result = regex.exec(src)

if (result && result[1]) {
writeKey = result[1]
break
}
}

if (!writeKey && document.currentScript) {
const script = document.currentScript as HTMLScriptElement
const src = script.src

const src = getTrustedScriptSrc()
if (src) {
const result = regex.exec(src)

if (result && result[1]) {
writeKey = result[1]
return result[1]
}
}

return writeKey
return undefined
}

export async function install(): Promise<void> {
Expand Down
10 changes: 9 additions & 1 deletion packages/browser/src/browser/standalone.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn'
import {
captureInitialScriptSrc,
getCDN,
setGlobalCDNUrl,
} from '../lib/parse-cdn'
import { setVersionType } from '../lib/version-type'

// SECOPS-25767: snapshot the loading tag's src at boot, while
// document.currentScript is still valid, before any async work runs.
captureInitialScriptSrc()

if (process.env.IS_WEBPACK_BUILD) {
if (process.env.ASSET_PATH) {
// @ts-ignore
Expand Down
176 changes: 119 additions & 57 deletions packages/browser/src/lib/__tests__/parse-cdn.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { JSDOM, VirtualConsole } from 'jsdom'
import { getCDN } from '../parse-cdn'

function withTag(tag: string) {
const html = `
<!DOCTYPE html>
<head>
${tag}
</head>
<body>
</body>
</html>
`.trim()

// parse-cdn snapshots document.currentScript at first use, so each test needs a
// fresh module instance. We re-require it after wiring up the DOM.
let getCDN: typeof import('../parse-cdn').getCDN

/**
* Render a document whose *loading* tag is `loaderSrc` (the one the browser
* would expose as document.currentScript), plus any number of `extraTags` that
* are present in the DOM but did NOT load the SDK (e.g. attacker-injected,
* possibly inert). Pass loaderSrc = null to model a context where
* document.currentScript is null (bundler/npm, async load, event handler).
*/
function withTags(loaderSrc: string | null, extraTags: string[] = []) {
const tags = [...extraTags, ...(loaderSrc ? [loaderSrc] : [])]
.map((src) => `<script src="${src}"></script>`)
.join('\n')

const html = `<!DOCTYPE html><head>${tags}</head><body></body>`.trim()

const virtualConsole = new VirtualConsole()
const jsd = new JSDOM(html, {
Expand All @@ -20,69 +26,125 @@ function withTag(tag: string) {
virtualConsole,
})

const windowSpy = jest.spyOn(global, 'window', 'get')

const documentSpy = jest.spyOn(global, 'document', 'get')
const doc = jsd.window.document

jest.spyOn(console, 'warn').mockImplementationOnce(() => {})

windowSpy.mockImplementation(() => {
return jsd.window as unknown as Window & typeof globalThis
// Point document.currentScript at the loader tag (the last one rendered),
// shadowing the prototype getter that otherwise returns null in jsdom.
const loaderEl = loaderSrc
? doc.querySelector(`script[src="${loaderSrc}"]`)
: null
Object.defineProperty(doc, 'currentScript', {
configurable: true,
get: () => loaderEl,
})

documentSpy.mockImplementation(
() => jsd.window.document as unknown as Document
)
jest.spyOn(console, 'warn').mockImplementationOnce(() => {})
jest
.spyOn(global, 'window', 'get')
.mockImplementation(
() => jsd.window as unknown as Window & typeof globalThis
)
jest
.spyOn(global, 'document', 'get')
.mockImplementation(() => doc as unknown as Document)
}

beforeEach(async () => {
jest.restoreAllMocks()
jest.resetAllMocks()
jest.resetModules()
;({ getCDN } = await import('../parse-cdn'))
})

const SEGMENT_TAG =
'https://cdn.segment.com/analytics.js/v1/gA5MBlJXrtZaB5sMMZvCF6czfBcfzNO6/analytics.min.js'
const CUSTOM_TAG =
'https://my.cdn.domain/analytics.js/v1/gA5MBlJXrtZaB5sMMZvCF6czfBcfzNO6/analytics.min.js'
const EVIL_TAG = 'https://evil.example.com/x/analytics.js/v1/evilkey/platform'

it('detects the existing segment cdn from the loading tag', () => {
withTags(SEGMENT_TAG)
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('returns the overridden cdn if window.analytics._cdn is set', () => {
withTags(SEGMENT_TAG)
;(window as any).analytics = { _cdn: 'http://foo.cdn.com' }
expect(getCDN()).toBe('http://foo.cdn.com')
})

it('detects custom / proxy cdns from the loading tag (proxy support preserved)', () => {
withTags(CUSTOM_TAG)
expect(getCDN()).toBe('https://my.cdn.domain')
})

it('falls back to Segment if the loading tag src does not match the pattern', () => {
withTags('https://my.cdn.proxy/custom-analytics.min.js')
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('detects the existing segment cdn', () => {
withTag(`
<script src="https://cdn.segment.com/analytics.js/v1/gA5MBlJXrtZaB5sMMZvCF6czfBcfzNO6/analytics.min.js" />
`)
expect(getCDN()).toMatchInlineSnapshot(`"https://cdn.segment.com"`)
it('falls back to Segment if there is no loading tag (currentScript null)', () => {
withTags(null)
expect(getCDN()).toBe('https://cdn.segment.com')
})

// --- SECOPS-25767 regression tests ---------------------------------------

it('ignores a non-loading (injected) tag and trusts only the loader', () => {
// legit tag loaded us; an attacker-injected evil tag is also in the DOM.
withTags(SEGMENT_TAG, [EVIL_TAG])
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('does NOT trust an injected tag when there is no valid loader', () => {
// no tag actually loaded us (currentScript null); an inert evil tag exists.
withTags(null, [EVIL_TAG])
expect(getCDN()).toBe('https://cdn.segment.com') // NOT evil.example.com
})

it('trusts the proxy loader even when an evil tag is also present', () => {
withTags(CUSTOM_TAG, [EVIL_TAG])
expect(getCDN()).toBe('https://my.cdn.domain')
})

// --- allowlisted fallback (no currentScript, e.g. bundler + snippet) --------

it('falls back to a public Segment CDN tag when currentScript is unavailable', () => {
withTags(null, [SEGMENT_TAG])
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('should return the overridden cdn if window.analytics._cdn is mutated', () => {
withTag(`
<script src="https://cdn.segment.com/analytics.js/v1/gA5MBlJXrtZaB5sMMZvCF6czfBcfzNO6/analytics.min.js" />
`)
// @ts-ignore
;(window.analytics as any) = {
_cdn: 'http://foo.cdn.com',
}
expect(getCDN()).toMatchInlineSnapshot(`"http://foo.cdn.com"`)
it('does NOT trust a path under an allowlisted origin (greedy-prefix abuse)', () => {
// The CDN base comes from the regex's greedy prefix capture, so a path under
// an allowlisted host must not be accepted - otherwise anyone who can
// publish under that path controls the settings we fetch.
withTags(null, [
'https://cdn.segment.com/npm/evil-pkg@1.0.0/analytics.js/v1/K/analytics.min.js',
])
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('if analytics is not loaded yet, should still return cdn', () => {
// is this an impossible state?
// @ts-ignore
window.analytics = undefined as any
withTag(`
<script src="https://cdn.segment.com/analytics.js/v1/gA5MBlJXrtZaB5sMMZvCF6czfBcfzNO6/analytics.min.js" />
`)
expect(getCDN()).toMatchInlineSnapshot(`"https://cdn.segment.com"`)
it('does NOT trust a self-serve mirror (cdn.jsdelivr.net is not allowlisted)', () => {
withTags(null, [
'https://cdn.jsdelivr.net/npm/evil-pkg@1.0.0/analytics.js/v1/K/analytics.min.js',
])
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('detects custom cdns that match Segment in domain instrumentation patterns', () => {
withTag(`
<script src="https://my.cdn.domain/analytics.js/v1/gA5MBlJXrtZaB5sMMZvCF6czfBcfzNO6/analytics.min.js" />
`)
expect(getCDN()).toMatchInlineSnapshot(`"https://my.cdn.domain"`)
it('does NOT fall back to a non-allowlisted tag (proxy) without currentScript', () => {
// a real proxy tag, but we cannot prove it loaded us -> refuse to trust it
withTags(null, [CUSTOM_TAG])
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('falls back to Segment if CDN is used as a proxy', () => {
withTag(`
<script src="https://my.cdn.proxy/custom-analytics.min.js" />
`)
expect(getCDN()).toMatchInlineSnapshot(`"https://cdn.segment.com"`)
it('does NOT fall back to an evil tag that mimics an allowlisted host', () => {
withTags(null, [
'https://cdn.segment.com.evil.example.com/analytics.js/v1/k/analytics.min.js',
])
expect(getCDN()).toBe('https://cdn.segment.com')
})

it('falls back to Segment if the script is not at all present on the page', () => {
withTag('')
expect(getCDN()).toMatchInlineSnapshot(`"https://cdn.segment.com"`)
it('ignores an evil tag and picks the allowlisted one in the fallback scan', () => {
withTags(null, [SEGMENT_TAG, EVIL_TAG])
expect(getCDN()).toBe('https://cdn.segment.com')
})
Loading
Loading