diff --git a/apps/web/src/lib/server/integrations/segment/__tests__/user-sync.test.ts b/apps/web/src/lib/server/integrations/segment/__tests__/user-sync.test.ts index 52de899e7..0bde1aee2 100644 --- a/apps/web/src/lib/server/integrations/segment/__tests__/user-sync.test.ts +++ b/apps/web/src/lib/server/integrations/segment/__tests__/user-sync.test.ts @@ -1,6 +1,63 @@ +import { createHmac } from 'crypto' import { beforeEach, describe, expect, it, vi } from 'vitest' import { segmentUserSync } from '../user-sync' +describe('segmentUserSync.handleIdentify', () => { + const body = JSON.stringify({ + type: 'identify', + userId: 'external-user-1', + traits: { email: 'user@example.com', plan: 'pro' }, + }) + + it('rejects unsigned identify requests when no incoming secret is configured', async () => { + const request = new Request('https://example.com/api/integrations/segment/identify', { + method: 'POST', + }) + + const result = await segmentUserSync.handleIdentify?.(request, body, {}, {}) + + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(401) + await expect((result as Response).text()).resolves.toBe( + 'Segment incoming secret is not configured' + ) + }) + + it('rejects identify requests when an incoming secret is configured but the signature is missing', async () => { + const request = new Request('https://example.com/api/integrations/segment/identify', { + method: 'POST', + }) + + const result = await segmentUserSync.handleIdentify?.( + request, + body, + { incomingSecret: 'segment-secret' }, + {} + ) + + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(401) + await expect((result as Response).text()).resolves.toBe('Missing x-signature header') + }) + + it('accepts identify requests with a valid signature', async () => { + const incomingSecret = 'segment-secret' + const signature = createHmac('sha1', incomingSecret).update(body).digest('base64') + const request = new Request('https://example.com/api/integrations/segment/identify', { + method: 'POST', + headers: { 'x-signature': signature }, + }) + + const result = await segmentUserSync.handleIdentify?.(request, body, { incomingSecret }, {}) + + expect(result).toEqual({ + email: 'user@example.com', + externalUserId: 'external-user-1', + attributes: { email: 'user@example.com', plan: 'pro' }, + }) + }) +}) + describe('segmentUserSync.syncSegmentMembership', () => { beforeEach(() => { vi.restoreAllMocks() diff --git a/apps/web/src/lib/server/integrations/segment/user-sync.ts b/apps/web/src/lib/server/integrations/segment/user-sync.ts index f1a4dba3d..d1658cfaf 100644 --- a/apps/web/src/lib/server/integrations/segment/user-sync.ts +++ b/apps/web/src/lib/server/integrations/segment/user-sync.ts @@ -27,25 +27,27 @@ const MAX_ERROR_BODY_LENGTH = 300 export const segmentUserSync: UserSyncHandler = { async handleIdentify(request, body, config, _secrets): Promise { - // Verify HMAC-SHA1 signature if a shared secret is configured. - // Segment signs the raw body with the source's shared secret. - const incomingSecret = config.incomingSecret as string | undefined - if (incomingSecret) { - const signature = request.headers.get('x-signature') - if (!signature) { - return new Response('Missing x-signature header', { status: 401 }) - } + // Segment identify webhooks mutate user metadata, so require a shared secret + // and verify every inbound request before parsing the payload. + const incomingSecret = config.incomingSecret + if (typeof incomingSecret !== 'string' || incomingSecret.trim().length === 0) { + return new Response('Segment incoming secret is not configured', { status: 401 }) + } - const expected = createHmac('sha1', incomingSecret).update(body).digest('base64') - try { - const sigBuf = Buffer.from(signature, 'base64') - const expBuf = Buffer.from(expected, 'base64') - if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) { - return new Response('Invalid signature', { status: 401 }) - } - } catch { + const signature = request.headers.get('x-signature') + if (!signature) { + return new Response('Missing x-signature header', { status: 401 }) + } + + const expected = createHmac('sha1', incomingSecret).update(body).digest('base64') + try { + const sigBuf = Buffer.from(signature, 'base64') + const expBuf = Buffer.from(expected, 'base64') + if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) { return new Response('Invalid signature', { status: 401 }) } + } catch { + return new Response('Invalid signature', { status: 401 }) } let payload: Record