From b2f479a3b028d70617c5399c5cf87e925c961611 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Thu, 30 Jul 2026 11:03:22 +0530 Subject: [PATCH 1/2] fix(security): reject URL injection in Qualtrics datacenter and Pipedrive domain settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates that user-supplied datacenter (Qualtrics) and domain (Pipedrive) fields contain only alphanumeric characters and hyphens before interpolating them into API base URLs. Without this check, an attacker-controlled value could redirect requests — including bearer tokens and API keys — to an arbitrary host. Fixes SECOPS-25215 (Qualtrics) and SECOPS-25240 (Pipedrive). Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/index.test.ts | 20 +++++++++++++++++++ .../src/destinations/pipedrive/index.ts | 2 ++ .../pipedriveApi/pipedrive-client.ts | 2 ++ .../src/destinations/pipedrive/utils.ts | 10 ++++++++++ .../qualtrics/__tests__/index.test.ts | 18 +++++++++++++++++ .../qualtrics/qualtricsApiClient.ts | 10 ++++++++-- 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts index f111694fa7b..93a3b9d2935 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts @@ -8,6 +8,26 @@ const PIPEDRIVE_API_KEY = 'random string' const PIPEDRIVE_DOMAIN = 'companydomain' const PERSON_ID = 33333 +describe('Pipedrive domain validation', () => { + it('should throw when domain contains URL injection characters', async () => { + await expect( + testDestination.testAction('createUpdatePerson', { + mapping: { name: 'Test', match_value: '123' }, + settings: { apiToken: PIPEDRIVE_API_KEY, domain: 'attacker.com/path?x=' } + }) + ).rejects.toThrowError(/Invalid domain/) + }) + + it('should throw when domain contains @ injection', async () => { + await expect( + testDestination.testAction('createUpdatePerson', { + mapping: { name: 'Test', match_value: '123' }, + settings: { apiToken: PIPEDRIVE_API_KEY, domain: 'attacker.com@legitimate' } + }) + ).rejects.toThrowError(/Invalid domain/) + }) +}) + describe('Pipedrive.createUpdatePerson', () => { it('should create person if none exists', async () => { const scope = nock(`https://${PIPEDRIVE_DOMAIN}.pipedrive.com/api/v1`) diff --git a/packages/destination-actions/src/destinations/pipedrive/index.ts b/packages/destination-actions/src/destinations/pipedrive/index.ts index d25e896ab4d..e177c330d22 100644 --- a/packages/destination-actions/src/destinations/pipedrive/index.ts +++ b/packages/destination-actions/src/destinations/pipedrive/index.ts @@ -2,6 +2,7 @@ import createUpdateOrganization from './createUpdateOrganization' import createUpdatePerson from './createUpdatePerson' import { defaultValues, DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' +import { validateDomain } from './utils' import createUpdateActivity from './createUpdateActivity' @@ -59,6 +60,7 @@ const destination: DestinationDefinition = { } }, testAuthentication: (request, { settings }) => { + validateDomain(settings.domain) return request(`https://${settings.domain}.pipedrive.com/api/v1/users/me`) } }, diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts index b15847d8994..01fd5353148 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts @@ -1,5 +1,6 @@ import { Settings } from '../generated-types' import type { ExecuteInput, ModifiedResponse, RequestClient } from '@segment/actions-core' +import { validateDomain } from '../utils' import get from 'lodash/get' import { ActivityTypes, PipedriveFields } from './domain' import { DynamicFieldResponse } from '@segment/actions-core' @@ -52,6 +53,7 @@ class PipedriveClient { private _request: RequestClient constructor(settings: Settings, request: RequestClient) { + validateDomain(settings.domain) this.settings = settings this._request = request } diff --git a/packages/destination-actions/src/destinations/pipedrive/utils.ts b/packages/destination-actions/src/destinations/pipedrive/utils.ts index 581966830b2..2b2a26c020e 100644 --- a/packages/destination-actions/src/destinations/pipedrive/utils.ts +++ b/packages/destination-actions/src/destinations/pipedrive/utils.ts @@ -1,5 +1,15 @@ +import { InvalidAuthenticationError } from '@segment/actions-core' + type PayloadWithCustomFields = { custom_fields?: { [k: string]: unknown } } +export function validateDomain(domain: string): void { + if (!/^[a-zA-Z0-9-]+$/.test(domain)) { + throw new InvalidAuthenticationError( + 'Invalid domain. Domain must contain only alphanumeric characters and hyphens.' + ) + } +} + export function addCustomFieldsFromPayloadToEntity(payload: PayloadWithCustomFields, entity: E) { if (!payload.custom_fields) { return diff --git a/packages/destination-actions/src/destinations/qualtrics/__tests__/index.test.ts b/packages/destination-actions/src/destinations/qualtrics/__tests__/index.test.ts index 8ac4fea8a0f..12073ce6fcf 100644 --- a/packages/destination-actions/src/destinations/qualtrics/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/qualtrics/__tests__/index.test.ts @@ -42,5 +42,23 @@ describe('Qualtrics', () => { await expect(testDestination.testAuthentication(authData)).rejects.toThrowError(/401/) }) + + it('throw error when datacenter contains URL injection characters', async () => { + const authData = { + apiToken: 'VALID_API_TOKEN_VALUE', + datacenter: 'attacker.com/path?x=' + } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError(/Invalid datacenter ID/) + }) + + it('throw error when datacenter contains @ injection', async () => { + const authData = { + apiToken: 'VALID_API_TOKEN_VALUE', + datacenter: 'attacker.com@legitimate' + } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError(/Invalid datacenter ID/) + }) }) }) diff --git a/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts b/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts index 16990aee750..dbb3bcec68b 100644 --- a/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts +++ b/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts @@ -1,4 +1,4 @@ -import { RequestClient } from '@segment/actions-core' +import { RequestClient, InvalidAuthenticationError } from '@segment/actions-core' export type SupportedMethods = 'get' | 'post' @@ -125,7 +125,13 @@ export default class QualtricsApiClient { private request: RequestClient constructor(dc: string, apiToken: string, request: RequestClient) { - this.baseUrl = `https://${dc || 'iad1'}.qualtrics.com` + const datacenter = dc || 'iad1' + if (!/^[a-zA-Z0-9-]+$/.test(datacenter)) { + throw new InvalidAuthenticationError( + 'Invalid datacenter ID. Datacenter must contain only alphanumeric characters and hyphens.' + ) + } + this.baseUrl = `https://${datacenter}.qualtrics.com` this.apiToken = apiToken this.request = request } From b0a6fbccac3a12c26f8c57f8a8fab371c5f08a0a Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 10 Aug 2026 13:16:14 +0530 Subject: [PATCH 2/2] fix(security): validate Qualtrics/Pipedrive host fields only in testAuthentication Moves datacenter/domain validation out of the per-request client constructors and into testAuthentication, the single place settings are verified. This avoids redundant validation on every perform call and fixes Qualtrics snapshot tests that failed when fuzzed settings didn't match the datacenter regex. --- .../createUpdatePerson/__tests__/index.test.ts | 10 ++-------- .../pipedrive/pipedriveApi/pipedrive-client.ts | 2 -- .../src/destinations/qualtrics/index.ts | 2 ++ .../destinations/qualtrics/qualtricsApiClient.ts | 7 +------ .../src/destinations/qualtrics/utils.ts | 16 +++++++++++++--- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts index 93a3b9d2935..b7eb789c736 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/__tests__/index.test.ts @@ -11,19 +11,13 @@ const PERSON_ID = 33333 describe('Pipedrive domain validation', () => { it('should throw when domain contains URL injection characters', async () => { await expect( - testDestination.testAction('createUpdatePerson', { - mapping: { name: 'Test', match_value: '123' }, - settings: { apiToken: PIPEDRIVE_API_KEY, domain: 'attacker.com/path?x=' } - }) + testDestination.testAuthentication({ apiToken: PIPEDRIVE_API_KEY, domain: 'attacker.com/path?x=' }) ).rejects.toThrowError(/Invalid domain/) }) it('should throw when domain contains @ injection', async () => { await expect( - testDestination.testAction('createUpdatePerson', { - mapping: { name: 'Test', match_value: '123' }, - settings: { apiToken: PIPEDRIVE_API_KEY, domain: 'attacker.com@legitimate' } - }) + testDestination.testAuthentication({ apiToken: PIPEDRIVE_API_KEY, domain: 'attacker.com@legitimate' }) ).rejects.toThrowError(/Invalid domain/) }) }) diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts index 01fd5353148..b15847d8994 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts @@ -1,6 +1,5 @@ import { Settings } from '../generated-types' import type { ExecuteInput, ModifiedResponse, RequestClient } from '@segment/actions-core' -import { validateDomain } from '../utils' import get from 'lodash/get' import { ActivityTypes, PipedriveFields } from './domain' import { DynamicFieldResponse } from '@segment/actions-core' @@ -53,7 +52,6 @@ class PipedriveClient { private _request: RequestClient constructor(settings: Settings, request: RequestClient) { - validateDomain(settings.domain) this.settings = settings this._request = request } diff --git a/packages/destination-actions/src/destinations/qualtrics/index.ts b/packages/destination-actions/src/destinations/qualtrics/index.ts index d60d7613143..680005bfad8 100644 --- a/packages/destination-actions/src/destinations/qualtrics/index.ts +++ b/packages/destination-actions/src/destinations/qualtrics/index.ts @@ -6,6 +6,7 @@ import addContactToXmd from './addContactToXmd' import upsertContactTransaction from './upsertContactTransaction' import triggerXflowWorkflow from './triggerXflowWorkflow' import QualtricsApiClient from './qualtricsApiClient' +import { validateDatacenter } from './utils' const destination: DestinationDefinition = { name: 'Qualtrics', @@ -31,6 +32,7 @@ const destination: DestinationDefinition = { } }, testAuthentication: async (request: RequestClient, input) => { + validateDatacenter(input.settings.datacenter) const apiClient = new QualtricsApiClient(input.settings.datacenter, input.settings.apiToken, request) const response = await apiClient.whoaAmI() return response.userName !== undefined diff --git a/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts b/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts index dbb3bcec68b..187fac4995b 100644 --- a/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts +++ b/packages/destination-actions/src/destinations/qualtrics/qualtricsApiClient.ts @@ -1,4 +1,4 @@ -import { RequestClient, InvalidAuthenticationError } from '@segment/actions-core' +import { RequestClient } from '@segment/actions-core' export type SupportedMethods = 'get' | 'post' @@ -126,11 +126,6 @@ export default class QualtricsApiClient { constructor(dc: string, apiToken: string, request: RequestClient) { const datacenter = dc || 'iad1' - if (!/^[a-zA-Z0-9-]+$/.test(datacenter)) { - throw new InvalidAuthenticationError( - 'Invalid datacenter ID. Datacenter must contain only alphanumeric characters and hyphens.' - ) - } this.baseUrl = `https://${datacenter}.qualtrics.com` this.apiToken = apiToken this.request = request diff --git a/packages/destination-actions/src/destinations/qualtrics/utils.ts b/packages/destination-actions/src/destinations/qualtrics/utils.ts index 13d2388c97a..035a8eb7dc3 100644 --- a/packages/destination-actions/src/destinations/qualtrics/utils.ts +++ b/packages/destination-actions/src/destinations/qualtrics/utils.ts @@ -1,3 +1,13 @@ +import { InvalidAuthenticationError } from '@segment/actions-core' + +export function validateDatacenter(datacenter: string): void { + if (!/^[a-zA-Z0-9-]+$/.test(datacenter)) { + throw new InvalidAuthenticationError( + 'Invalid datacenter ID. Datacenter must contain only alphanumeric characters and hyphens.' + ) + } +} + export function parsedEmbeddedData( data: { [key: string]: unknown } | undefined ): Record { @@ -7,11 +17,11 @@ export function parsedEmbeddedData( return } if (typeof data[key] === 'string') { - parsedData[key] = data[key] as string + parsedData[key] = data[key] } else if (typeof data[key] === 'number') { - parsedData[key] = data[key] as number + parsedData[key] = data[key] } else if (typeof data[key] === 'boolean') { - parsedData[key] = data[key] as boolean + parsedData[key] = data[key] } else { try { parsedData[key] = JSON.stringify(data)