Skip to content
Merged
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
21 changes: 21 additions & 0 deletions packages/ai-proxy/src/integrations/fetch-without-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AIToolUnprocessableError } from '../errors';

const MIN_REDIRECT_STATUS = 300;
const MAX_REDIRECT_STATUS = 399;

// The integration APIs never redirect, so a redirect is refused rather than followed to a URL
// that was never validated.
export default async function fetchWithoutRedirect(
url: string,
init: RequestInit,
): Promise<Response> {
const response = await fetch(url, { ...init, redirect: 'manual' });
Comment thread
PMerlet marked this conversation as resolved.

if (response.status >= MIN_REDIRECT_STATUS && response.status <= MAX_REDIRECT_STATUS) {
throw new AIToolUnprocessableError(
`Refused to follow a redirect (HTTP ${response.status}) from ${new URL(url).origin}`,
);
}

return response;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import { AIBadRequestError } from '../../../errors';
import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createCortexAnalystTool(
Expand Down Expand Up @@ -52,7 +53,7 @@ export default function createCortexAnalystTool(
...(semantic_view && { semantic_view }),
};

const response = await fetch(`${baseUrl}/api/v2/cortex/analyst/message`, {
const response = await fetchWithoutRedirect(`${baseUrl}/api/v2/cortex/analyst/message`, {
method: 'POST',
headers,
body: JSON.stringify(body),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createCortexSearchTool(
Expand Down Expand Up @@ -45,7 +46,7 @@ export default function createCortexSearchTool(
if (filter) body.filter = filter;
if (limit !== undefined) body.limit = limit;

const response = await fetch(url, {
const response = await fetchWithoutRedirect(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SnowflakeConfig } from '../tools';
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertReadOnlySql, assertResponseOk, buildSessionContext } from '../utils';

export default function createExecuteQueryTool(
Expand Down Expand Up @@ -44,7 +45,7 @@ export default function createExecuteQueryTool(
...(role && { role }),
};

const response = await fetch(`${baseUrl}/api/v2/statements`, {
const response = await fetchWithoutRedirect(`${baseUrl}/api/v2/statements`, {
method: 'POST',
headers,
body: JSON.stringify(body),
Expand Down
3 changes: 2 additions & 1 deletion packages/ai-proxy/src/integrations/snowflake/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SnowflakeConfig } from './tools';

import { AIBadRequestError, AIToolUnprocessableError, McpConnectionError } from '../../errors';
import fetchWithoutRedirect from '../fetch-without-redirect';

const READ_ONLY_LEADING_KEYWORD_RE = /^\s*(select|show|describe|desc|explain)\b/i;
const FORBIDDEN_KEYWORD_RE =
Expand Down Expand Up @@ -144,7 +145,7 @@ export async function validateSnowflakeConfig(config: SnowflakeConfig) {
};
const url = `${baseUrl}/api/v2/statements`;

const response = await fetch(url, {
const response = await fetchWithoutRedirect(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createCreateTicketCommentTool(
Expand Down Expand Up @@ -31,7 +32,7 @@ export default function createCreateTicketCommentTool(
},
};

const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, {
const response = await fetchWithoutRedirect(`${baseUrl}/tickets/${ticket_id}.json`, {
method: 'PUT',
headers,
body: JSON.stringify(updateData),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createCreateTicketTool(
Expand Down Expand Up @@ -79,7 +80,7 @@ export default function createCreateTicketTool(
},
};

const response = await fetch(`${baseUrl}/tickets.json`, {
const response = await fetchWithoutRedirect(`${baseUrl}/tickets.json`, {
method: 'POST',
headers,
body: JSON.stringify(ticketData),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createGetTicketCommentsTool(
Expand All @@ -14,7 +15,7 @@ export default function createGetTicketCommentsTool(
ticket_id: z.number().int().positive().describe('The ID of the ticket to get comments for'),
}),
func: async ({ ticket_id }) => {
const response = await fetch(`${baseUrl}/tickets/${ticket_id}/comments.json`, {
const response = await fetchWithoutRedirect(`${baseUrl}/tickets/${ticket_id}/comments.json`, {
headers,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createGetTicketTool(
Expand All @@ -14,7 +15,7 @@ export default function createGetTicketTool(
ticket_id: z.number().int().positive().describe('The ID of the ticket to retrieve'),
}),
func: async ({ ticket_id }) => {
const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, {
const response = await fetchWithoutRedirect(`${baseUrl}/tickets/${ticket_id}.json`, {
headers,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

const searchFiltersSchema = z.object({
Expand Down Expand Up @@ -129,7 +130,9 @@ export default function createGetTicketsTool(
sort_order: sortOrder,
});

const response = await fetch(`${baseUrl}/search.json?${params}`, { headers });
const response = await fetchWithoutRedirect(`${baseUrl}/search.json?${params}`, {
headers,
});
await assertResponseOk(response, 'search tickets');

return JSON.stringify(await response.json());
Expand All @@ -142,7 +145,7 @@ export default function createGetTicketsTool(
sort_order: sortOrder,
});

const response = await fetch(`${baseUrl}/tickets.json?${params}`, { headers });
const response = await fetchWithoutRedirect(`${baseUrl}/tickets.json?${params}`, { headers });
await assertResponseOk(response, 'get tickets');

return JSON.stringify(await response.json());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';

import fetchWithoutRedirect from '../../fetch-without-redirect';
import { assertResponseOk } from '../utils';

export default function createUpdateTicketTool(
Expand Down Expand Up @@ -76,7 +77,7 @@ export default function createUpdateTicketTool(
if (custom_fields !== undefined) ticketUpdate.custom_fields = custom_fields;
if (due_at !== undefined) ticketUpdate.due_at = due_at;

const response = await fetch(`${baseUrl}/tickets/${ticket_id}.json`, {
const response = await fetchWithoutRedirect(`${baseUrl}/tickets/${ticket_id}.json`, {
method: 'PUT',
headers,
body: JSON.stringify({ ticket: ticketUpdate }),
Expand Down
17 changes: 15 additions & 2 deletions packages/ai-proxy/src/integrations/zendesk/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import type { ZendeskConfig } from './tools';

import { AIToolUnprocessableError, McpConnectionError } from '../../errors';
import { AIBadRequestError, AIToolUnprocessableError, McpConnectionError } from '../../errors';
import fetchWithoutRedirect from '../fetch-without-redirect';

// A Zendesk subdomain is a single DNS label. Anything else could redirect URL building to an
// attacker-controlled host (e.g. `attack.com/x#` makes `#` start the URL fragment).
const SUBDOMAIN_RE = /^[A-Za-z0-9-]{1,63}$/;

export function assertValidSubdomain(subdomain: string): void {
if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) {
throw new AIBadRequestError(`Invalid Zendesk subdomain: ${JSON.stringify(subdomain)}`);
}
}

export function getZendeskConfig(config: ZendeskConfig) {
assertValidSubdomain(config.subdomain);
Comment thread
Tonours marked this conversation as resolved.

const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`;
const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64');
const headers = {
Expand Down Expand Up @@ -33,7 +46,7 @@ export async function assertResponseOk(response: Response, action: string) {
export async function validateZendeskConfig(config: ZendeskConfig) {
const { baseUrl, headers } = getZendeskConfig(config);

const response = await fetch(`${baseUrl}/users/me`, { headers });
const response = await fetchWithoutRedirect(`${baseUrl}/users/me`, { headers });

if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
Expand Down
92 changes: 92 additions & 0 deletions packages/ai-proxy/test/integrations/fetch-without-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { AddressInfo } from 'net';

import http from 'http';

import { AIToolUnprocessableError } from '../../src/errors';
import fetchWithoutRedirect from '../../src/integrations/fetch-without-redirect';
import createExecuteQueryTool from '../../src/integrations/snowflake/tools/execute-query';
import createGetTicketTool from '../../src/integrations/zendesk/tools/get-ticket';

function listen(server: http.Server): Promise<string> {
return new Promise(resolve => {
server.listen(0, '127.0.0.1', () => {
resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
});
});
}

describe('fetchWithoutRedirect', () => {
const internalHits: string[] = [];
const internal = http.createServer((req, res) => {
internalHits.push(req.url);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ secret: 'internal' }));
});
let internalUrl: string;

let redirectStatus: number;
const redirecting = http.createServer((req, res) => {
res.writeHead(redirectStatus, { location: `${internalUrl}/latest/meta-data/` });
res.end();
});
let redirectingUrl: string;

const plain = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ticket: { id: 1 } }));
});
let plainUrl: string;

beforeAll(async () => {
[internalUrl, redirectingUrl, plainUrl] = await Promise.all(
[internal, redirecting, plain].map(listen),
);
});

afterAll(() => {
[internal, redirecting, plain].forEach(server => server.close());
});

beforeEach(() => {
internalHits.length = 0;
});

it('should return the response when the host does not redirect', async () => {
const response = await fetchWithoutRedirect(`${plainUrl}/tickets/1.json`, {});

expect(await response.json()).toEqual({ ticket: { id: 1 } });
});

it.each([301, 302, 303, 307, 308])(
'should refuse a %i redirect without requesting its target',
async status => {
redirectStatus = status;

await expect(fetchWithoutRedirect(`${redirectingUrl}/tickets/1.json`, {})).rejects.toThrow(
new AIToolUnprocessableError(
`Refused to follow a redirect (HTTP ${status}) from ${redirectingUrl}`,
),
);
expect(internalHits).toEqual([]);
},
);

it('should keep a Zendesk tool from returning what a redirect points to', async () => {
redirectStatus = 302;
const tool = createGetTicketTool({ Authorization: 'Basic abc' }, redirectingUrl);

await expect(tool.invoke({ ticket_id: 1 })).rejects.toThrow(AIToolUnprocessableError);
expect(internalHits).toEqual([]);
});

it('should keep a Snowflake tool from replaying its query to what a redirect points to', async () => {
redirectStatus = 307;
const tool = createExecuteQueryTool({ Authorization: 'Bearer abc' }, redirectingUrl, {
accountIdentifier: 'acme',
programmaticAccessToken: 'token',
});

await expect(tool.invoke({ statement: 'SELECT 1' })).rejects.toThrow(AIToolUnprocessableError);
expect(internalHits).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('createCortexAnalystTool', () => {
});

expect(fetch).toHaveBeenCalledWith(`${baseUrl}/api/v2/cortex/analyst/message`, {
redirect: 'manual',
method: 'POST',
headers,
body: JSON.stringify({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('createCortexSearchTool', () => {
expect(fetch).toHaveBeenCalledWith(
`${baseUrl}/api/v2/databases/MY_DB/schemas/MY_SCHEMA/cortex-search-services/MY_SVC:query`,
{
redirect: 'manual',
method: 'POST',
headers,
body: JSON.stringify({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('createExecuteQueryTool', () => {
const result = await tool.invoke({ statement: 'SELECT 1' });

expect(fetch).toHaveBeenCalledWith(`${baseUrl}/api/v2/statements`, {
redirect: 'manual',
method: 'POST',
headers,
body: JSON.stringify({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('createCreateTicketCommentTool', () => {
const result = await tool.invoke({ ticket_id: 5, comment: 'Looks good' });

expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/5.json`, {
redirect: 'manual',
method: 'PUT',
headers,
body: JSON.stringify({
Expand All @@ -49,6 +50,7 @@ describe('createCreateTicketCommentTool', () => {
await tool.invoke({ ticket_id: 5, comment: 'Internal note', public: false });

expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/5.json`, {
redirect: 'manual',
method: 'PUT',
headers,
body: JSON.stringify({
Expand Down
Loading
Loading