diff --git a/.changeset/secops-25767-cdn-trust.md b/.changeset/secops-25767-cdn-trust.md new file mode 100644 index 000000000..aa0233af2 --- /dev/null +++ b/.changeset/secops-25767-cdn-trust.md @@ -0,0 +1,49 @@ +--- +'@segment/analytics-next': minor +--- + +Harden CDN and write-key resolution (SECOPS-25767). + +**Behavior change — read this if you proxy or self-host the SDK.** The SDK no +longer discovers its CDN or write key by scanning arbitrary ``) + .join('\n') + + const html = `
${tags}`.trim() const virtualConsole = new VirtualConsole() const jsd = new JSDOM(html, { @@ -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(` - - `) - 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(` - - `) - // @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(` - - `) - 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(` - - `) - 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(` - - `) - 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') }) diff --git a/packages/browser/src/lib/parse-cdn.ts b/packages/browser/src/lib/parse-cdn.ts index 7c9531fca..dada36072 100644 --- a/packages/browser/src/lib/parse-cdn.ts +++ b/packages/browser/src/lib/parse-cdn.ts @@ -3,20 +3,110 @@ import { embeddedWriteKey } from './embedded-write-key' const analyticsScriptRegex = /(https:\/\/.*)\/analytics\.js\/v1\/(?:.*?)\/(?:platform|analytics.*)?/ -const getCDNUrlFromScriptTag = (): string | undefined => { - let cdn: string | undefined + +/** + * The `src` of the script tag that actually loaded the SDK, snapshotted at boot. + * + * SECOPS-25767: previously the CDN was resolved by scanning EVERY