From c6b04302d99cbe5be6bfd766ed3fcb66bf08a7f7 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Wed, 26 Aug 2026 14:21:08 +0530 Subject: [PATCH] fix(core): validate CDN-supplied apiHost before building upload URL --- devbox.lock | 2 +- packages/core/src/__tests__/util.test.ts | 44 ++++++++++++++++++- .../core/src/plugins/SegmentDestination.ts | 11 +++-- .../__tests__/SegmentDestination.test.ts | 22 ++++++++++ packages/core/src/util.ts | 10 +++++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/devbox.lock b/devbox.lock index 8fd623830..63e5a592d 100644 --- a/devbox.lock +++ b/devbox.lock @@ -199,7 +199,7 @@ }, "nodejs@22": { "last_modified": "2026-03-27T11:17:38Z", - "plugin_version": "0.0.4", + "plugin_version": "0.0.2", "resolved": "github:NixOS/nixpkgs/832efc09b4caf6b4569fbf9dc01bec3082a00611#nodejs_22", "source": "devbox-search", "version": "22.22.2", diff --git a/packages/core/src/__tests__/util.test.ts b/packages/core/src/__tests__/util.test.ts index 83eeefab2..bdc03e049 100644 --- a/packages/core/src/__tests__/util.test.ts +++ b/packages/core/src/__tests__/util.test.ts @@ -1,5 +1,11 @@ import { UserTraits } from '../types'; -import { chunk, allSettled, deepCompare, getURL } from '../util'; +import { + chunk, + allSettled, + deepCompare, + getURL, + validateApiHost, +} from '../util'; describe('#chunk', () => { it('handles empty array', () => { @@ -210,3 +216,39 @@ describe('getURL function', () => { ); }); }); + +describe('validateApiHost', () => { + it('accepts a bare hostname', () => { + expect(validateApiHost('api.segment.io')).toBe(true); + }); + + it('accepts hostname with path (normal Segment format)', () => { + expect(validateApiHost('api.segment.io/v1')).toBe(true); + expect(validateApiHost('events.eu1.segmentapis.com')).toBe(true); + }); + + it('accepts hostname with port', () => { + expect(validateApiHost('api.segment.io:443/v1')).toBe(true); + }); + + it('rejects values with a scheme', () => { + expect(validateApiHost('https://api.segment.io/v1')).toBe(false); + expect(validateApiHost('http://api.segment.io/v1')).toBe(false); + }); + + it('rejects values with credentials', () => { + expect(validateApiHost('user:pass@api.segment.io')).toBe(false); + }); + + it('rejects values with a query string', () => { + expect(validateApiHost('attacker.com/collect?x=')).toBe(false); + }); + + it('rejects values with a fragment', () => { + expect(validateApiHost('attacker.com/path#fragment')).toBe(false); + }); + + it('rejects empty string', () => { + expect(validateApiHost('')).toBe(false); + }); +}); diff --git a/packages/core/src/plugins/SegmentDestination.ts b/packages/core/src/plugins/SegmentDestination.ts index ba77d8d94..d21075065 100644 --- a/packages/core/src/plugins/SegmentDestination.ts +++ b/packages/core/src/plugins/SegmentDestination.ts @@ -10,7 +10,7 @@ import { SegmentEvent, UpdateType, } from '../types'; -import { chunk, createPromise, getURL } from '../util'; +import { chunk, createPromise, getURL, validateApiHost } from '../util'; import { uploadEvents } from '../api'; import type { SegmentClient } from '../analytics'; import { DestinationMetadataEnrichment } from './DestinationMetadataEnrichment'; @@ -446,8 +446,13 @@ export class SegmentDestination extends DestinationPlugin { segmentSettings?.apiHost !== undefined && segmentSettings?.apiHost !== null ) { - //assign the api host from segment settings (domain/v1) - this.apiHost = `https://${segmentSettings.apiHost}/b`; + if (validateApiHost(segmentSettings.apiHost)) { + this.apiHost = `https://${segmentSettings.apiHost}/b`; + } else { + console.error( + `[Segment] Invalid apiHost "${segmentSettings.apiHost}" received from settings — ignoring, using default endpoint.` + ); + } } // Read httpConfig: prefer integration-level settings from CDN, fall back to diff --git a/packages/core/src/plugins/__tests__/SegmentDestination.test.ts b/packages/core/src/plugins/__tests__/SegmentDestination.test.ts index bdb29d5c9..e187771a4 100644 --- a/packages/core/src/plugins/__tests__/SegmentDestination.test.ts +++ b/packages/core/src/plugins/__tests__/SegmentDestination.test.ts @@ -375,6 +375,28 @@ describe('SegmentDestination', () => { }); }); + it('ignores a tampered apiHost containing a scheme or query string and falls back to default', async () => { + const events = [{ messageId: 'msg-1' }] as SegmentEvent[]; + + for (const badHost of [ + 'https://attacker.com/collect', + 'http://attacker.com', + 'attacker.com/collect?x=', + 'user:pass@attacker.com', + ]) { + const { plugin, sendEventsSpy } = createTestWith({ + events, + settings: { apiKey: '', apiHost: badHost }, + }); + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + await plugin.flush(); + expect(sendEventsSpy).toHaveBeenCalledWith( + expect.objectContaining({ url: defaultApiHost }) + ); + sendEventsSpy.mockClear(); + } + }); + it.each([ [false, false], // No proxy, No segment endpoint [false, true], // No proxy, Yes segment endpoint diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index f6440ff85..8a89a8a78 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -258,6 +258,16 @@ export const createPromise = ( }; }; +// Accepts a bare host[/path] value (e.g. "api.segment.io/v1") as supplied by +// the settings CDN. Rejects anything that contains a scheme, credentials, +// query string, or fragment — all of which could redirect uploads to an +// attacker-controlled endpoint when interpolated into `https://${apiHost}/b`. +export function validateApiHost(apiHost: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9._-]*(:\d{2,5})?(\/[a-zA-Z0-9._\-/]*)?$/.test( + apiHost + ); +} + export function getURL(host: string, path: string, allowInsecure = false) { if (!host.startsWith('https://') && !host.startsWith('http://')) { host = 'https://' + host;