Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/klaviyo/functions/__tests__/proxyRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handler } from '../proxyRequest';

function buildEvent(body: Record<string, any>) {
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();
});
});
5 changes: 2 additions & 3 deletions apps/klaviyo/functions/proxyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,8 +58,7 @@ export const handler: FunctionEventHandler<FunctionTypeEnum.AppActionCall> = 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}`;
Expand Down
2 changes: 1 addition & 1 deletion apps/klaviyo/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'],
Expand Down
Loading