diff --git a/apps/klaviyo/functions/__tests__/proxyRequest.test.ts b/apps/klaviyo/functions/__tests__/proxyRequest.test.ts new file mode 100644 index 0000000000..a55a5c9724 --- /dev/null +++ b/apps/klaviyo/functions/__tests__/proxyRequest.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handler } from '../proxyRequest'; + +function buildEvent(body: Record) { + return { body } as any; +} + +function buildContext( + token: { tokenType: string; accessToken: string } = { + tokenType: 'Bearer', + accessToken: 'test-access-token', + } +) { + return { + oauthSdk: { + token: vi.fn().mockResolvedValue(token), + }, + } as any; +} + +describe('proxyRequest endpoint allowlist', () => { + beforeEach(() => { + (global.fetch as any) = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }); + }); + + it('allows an exact allowlisted endpoint', async () => { + const result = await handler( + buildEvent({ + endpoint: 'template-universal-content', + method: 'GET', + data: { foo: 'bar' }, + params: { page: 1 }, + }), + buildContext() + ); + + expect(result).not.toEqual({ response: { error: 'Endpoint not allowed' } }); + expect(global.fetch).toHaveBeenCalledOnce(); + }); + + it('allows an allowlisted endpoint followed by a single id segment', async () => { + const result = await handler( + buildEvent({ + endpoint: 'template-universal-content/abc123', + method: 'GET', + data: { foo: 'bar' }, + params: { page: 1 }, + }), + buildContext() + ); + + expect(result).not.toEqual({ response: { error: 'Endpoint not allowed' } }); + expect(global.fetch).toHaveBeenCalledOnce(); + }); + + it('rejects a path-traversal endpoint that resolves outside the allowlist', async () => { + const result = await handler( + buildEvent({ + endpoint: 'template-universal-content/../lists', + method: 'GET', + data: { foo: 'bar' }, + params: { page: 1 }, + }), + buildContext() + ); + + expect(result).toEqual({ response: { error: 'Endpoint not allowed' } }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('rejects an endpoint not on the allowlist at all', async () => { + const result = await handler( + buildEvent({ + endpoint: 'lists', + method: 'GET', + data: { foo: 'bar' }, + params: { page: 1 }, + }), + buildContext() + ); + + expect(result).toEqual({ response: { error: 'Endpoint not allowed' } }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('rejects an endpoint with a trailing traversal segment appended to a real id', async () => { + const result = await handler( + buildEvent({ + endpoint: 'template-universal-content/abc123/../../lists', + method: 'GET', + data: { foo: 'bar' }, + params: { page: 1 }, + }), + buildContext() + ); + + expect(result).toEqual({ response: { error: 'Endpoint not allowed' } }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/klaviyo/functions/proxyRequest.ts b/apps/klaviyo/functions/proxyRequest.ts index 39cbcf7dbe..cbbc992fd8 100644 --- a/apps/klaviyo/functions/proxyRequest.ts +++ b/apps/klaviyo/functions/proxyRequest.ts @@ -7,7 +7,7 @@ import type { const KLAVIYO_API_URL = 'https://a.klaviyo.com/api'; const KLAVIYO_API_REVISION = '2025-04-15'; -const ALLOWED_ENDPOINTS = ['template-universal-content', 'images']; +const ALLOWED_ENDPOINT_PATTERN = /^(template-universal-content|images)(\/[A-Za-z0-9_-]+)?$/; type AppActionParameters = { endpoint: string; @@ -58,8 +58,7 @@ export const handler: FunctionEventHandler = asy console.error('Missing params'); return { response: { error: 'Missing required params' } }; } - const baseEndpoint = endpoint.split('/')[0]; - if (!ALLOWED_ENDPOINTS.includes(baseEndpoint)) + if (!ALLOWED_ENDPOINT_PATTERN.test(endpoint)) return { response: { error: 'Endpoint not allowed' } }; const formattedEndpoint = endpoint.endsWith('/') ? endpoint : `${endpoint}/`; let url = `${KLAVIYO_API_URL}/${formattedEndpoint}`; diff --git a/apps/klaviyo/vitest.config.ts b/apps/klaviyo/vitest.config.ts index b52b255bd3..be32b2f2a4 100644 --- a/apps/klaviyo/vitest.config.ts +++ b/apps/klaviyo/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./test/setup.ts'], - include: ['src/**/*.{test,spec}.{ts,tsx}'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'functions/**/*.{test,spec}.{ts,tsx}'], coverage: { reporter: ['text', 'json', 'html'], exclude: ['**/node_modules/**', '**/dist/**', '**/coverage/**'],