From 08f5247f927994b6a8ce50f76a1956f80ab16952 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Fri, 23 Oct 2026 18:46:20 +0200 Subject: [PATCH] fix(ai-proxy): validate the Zendesk subdomain and refuse redirects on integration requests The Zendesk subdomain must now be a single DNS label, and Zendesk and Snowflake integration requests fail with a 422 on any redirect. fixes PRD-1724 Co-Authored-By: Claude Opus 5.5 --- .../integrations/fetch-without-redirect.ts | 21 +++++ .../snowflake/tools/cortex-analyst.ts | 3 +- .../snowflake/tools/cortex-search.ts | 3 +- .../snowflake/tools/execute-query.ts | 3 +- .../src/integrations/snowflake/utils.ts | 3 +- .../zendesk/tools/create-ticket-comment.ts | 3 +- .../zendesk/tools/create-ticket.ts | 3 +- .../zendesk/tools/get-ticket-comments.ts | 3 +- .../integrations/zendesk/tools/get-ticket.ts | 3 +- .../integrations/zendesk/tools/get-tickets.ts | 7 +- .../zendesk/tools/update-ticket.ts | 3 +- .../src/integrations/zendesk/utils.ts | 17 +++- .../fetch-without-redirect.test.ts | 92 +++++++++++++++++++ .../snowflake/tools/cortex-analyst.test.ts | 1 + .../snowflake/tools/cortex-search.test.ts | 1 + .../snowflake/tools/execute-query.test.ts | 1 + .../tools/create-ticket-comment.test.ts | 2 + .../zendesk/tools/create-ticket.test.ts | 7 ++ .../zendesk/tools/get-ticket-comments.test.ts | 5 +- .../zendesk/tools/get-ticket.test.ts | 5 +- .../zendesk/tools/get-tickets.test.ts | 44 +++++---- .../zendesk/tools/update-ticket.test.ts | 3 + .../test/integrations/zendesk/utils.test.ts | 42 ++++++++- 23 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 packages/ai-proxy/src/integrations/fetch-without-redirect.ts create mode 100644 packages/ai-proxy/test/integrations/fetch-without-redirect.test.ts diff --git a/packages/ai-proxy/src/integrations/fetch-without-redirect.ts b/packages/ai-proxy/src/integrations/fetch-without-redirect.ts new file mode 100644 index 0000000000..1a19208e1f --- /dev/null +++ b/packages/ai-proxy/src/integrations/fetch-without-redirect.ts @@ -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 { + const response = await fetch(url, { ...init, redirect: 'manual' }); + + 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; +} diff --git a/packages/ai-proxy/src/integrations/snowflake/tools/cortex-analyst.ts b/packages/ai-proxy/src/integrations/snowflake/tools/cortex-analyst.ts index 7a99674ad8..94af9d892c 100644 --- a/packages/ai-proxy/src/integrations/snowflake/tools/cortex-analyst.ts +++ b/packages/ai-proxy/src/integrations/snowflake/tools/cortex-analyst.ts @@ -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( @@ -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), diff --git a/packages/ai-proxy/src/integrations/snowflake/tools/cortex-search.ts b/packages/ai-proxy/src/integrations/snowflake/tools/cortex-search.ts index e011d8dd6d..af2290904f 100644 --- a/packages/ai-proxy/src/integrations/snowflake/tools/cortex-search.ts +++ b/packages/ai-proxy/src/integrations/snowflake/tools/cortex-search.ts @@ -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( @@ -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), diff --git a/packages/ai-proxy/src/integrations/snowflake/tools/execute-query.ts b/packages/ai-proxy/src/integrations/snowflake/tools/execute-query.ts index aa0ea433cd..07849c2f77 100644 --- a/packages/ai-proxy/src/integrations/snowflake/tools/execute-query.ts +++ b/packages/ai-proxy/src/integrations/snowflake/tools/execute-query.ts @@ -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( @@ -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), diff --git a/packages/ai-proxy/src/integrations/snowflake/utils.ts b/packages/ai-proxy/src/integrations/snowflake/utils.ts index 40f146df79..3a20e4fd0b 100644 --- a/packages/ai-proxy/src/integrations/snowflake/utils.ts +++ b/packages/ai-proxy/src/integrations/snowflake/utils.ts @@ -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 = @@ -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), diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts index 6007f59336..469d424fa2 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket-comment.ts @@ -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( @@ -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), diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts index 022fefb88d..b28b0b41b9 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/create-ticket.ts @@ -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( @@ -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), diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts index 81019a0cec..aa42e8e0c9 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket-comments.ts @@ -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( @@ -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, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts index 96546a6674..3185da3df1 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-ticket.ts @@ -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( @@ -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, }); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts index 6a51caf560..e8da2ef184 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/get-tickets.ts @@ -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({ @@ -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()); @@ -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()); diff --git a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts index c5e7a9e70c..dcda783a43 100644 --- a/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts +++ b/packages/ai-proxy/src/integrations/zendesk/tools/update-ticket.ts @@ -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( @@ -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 }), diff --git a/packages/ai-proxy/src/integrations/zendesk/utils.ts b/packages/ai-proxy/src/integrations/zendesk/utils.ts index bb85603dec..d5c216a8ff 100644 --- a/packages/ai-proxy/src/integrations/zendesk/utils.ts +++ b/packages/ai-proxy/src/integrations/zendesk/utils.ts @@ -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); + const baseUrl = `https://${config.subdomain}.zendesk.com/api/v2`; const auth = Buffer.from(`${config.email}/token:${config.apiToken}`).toString('base64'); const headers = { @@ -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'; diff --git a/packages/ai-proxy/test/integrations/fetch-without-redirect.test.ts b/packages/ai-proxy/test/integrations/fetch-without-redirect.test.ts new file mode 100644 index 0000000000..94021af426 --- /dev/null +++ b/packages/ai-proxy/test/integrations/fetch-without-redirect.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/packages/ai-proxy/test/integrations/snowflake/tools/cortex-analyst.test.ts b/packages/ai-proxy/test/integrations/snowflake/tools/cortex-analyst.test.ts index 793bca7bed..395762813b 100644 --- a/packages/ai-proxy/test/integrations/snowflake/tools/cortex-analyst.test.ts +++ b/packages/ai-proxy/test/integrations/snowflake/tools/cortex-analyst.test.ts @@ -22,6 +22,7 @@ describe('createCortexAnalystTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/api/v2/cortex/analyst/message`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ diff --git a/packages/ai-proxy/test/integrations/snowflake/tools/cortex-search.test.ts b/packages/ai-proxy/test/integrations/snowflake/tools/cortex-search.test.ts index affd2d48c0..2105883a55 100644 --- a/packages/ai-proxy/test/integrations/snowflake/tools/cortex-search.test.ts +++ b/packages/ai-proxy/test/integrations/snowflake/tools/cortex-search.test.ts @@ -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({ diff --git a/packages/ai-proxy/test/integrations/snowflake/tools/execute-query.test.ts b/packages/ai-proxy/test/integrations/snowflake/tools/execute-query.test.ts index fd9613e2cc..1dbe51b8a9 100644 --- a/packages/ai-proxy/test/integrations/snowflake/tools/execute-query.test.ts +++ b/packages/ai-proxy/test/integrations/snowflake/tools/execute-query.test.ts @@ -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({ diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts index ab591c5efa..f4ff8558be 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket-comment.test.ts @@ -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({ @@ -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({ diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts index bfe54bfddf..7511f27258 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/create-ticket.test.ts @@ -34,6 +34,7 @@ describe('createCreateTicketTool', () => { const result = await tool.invoke({ subject: 'Bug', description: 'It broke' }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ ticket: { subject: 'Bug', comment: { body: 'It broke' } } }), @@ -57,6 +58,7 @@ describe('createCreateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ @@ -85,6 +87,7 @@ describe('createCreateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ @@ -107,6 +110,7 @@ describe('createCreateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ @@ -129,6 +133,7 @@ describe('createCreateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ @@ -152,6 +157,7 @@ describe('createCreateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ @@ -174,6 +180,7 @@ describe('createCreateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json`, { + redirect: 'manual', method: 'POST', headers, body: JSON.stringify({ diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts index cf1e71be16..dcb9f3128b 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket-comments.test.ts @@ -33,7 +33,10 @@ describe('createGetTicketCommentsTool', () => { const result = await tool.invoke({ ticket_id: 10 }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/10/comments.json`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/10/comments.json`, { + redirect: 'manual', + headers, + }); expect(result).toBe(JSON.stringify(mockResponse)); }); }); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts index 6c7307c3bc..0e38c01007 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-ticket.test.ts @@ -33,7 +33,10 @@ describe('createGetTicketTool', () => { const result = await tool.invoke({ ticket_id: 42 }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/42.json`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/42.json`, { + redirect: 'manual', + headers, + }); expect(result).toBe(JSON.stringify(mockResponse)); }); }); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts index bb2b685345..ea78043fd4 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/get-tickets.test.ts @@ -39,7 +39,10 @@ describe('createGetTicketsTool', () => { sort_by: 'created_at', sort_order: 'desc', }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { + redirect: 'manual', + headers, + }); expect(result).toBe(JSON.stringify(mockResponse)); }); @@ -54,7 +57,10 @@ describe('createGetTicketsTool', () => { sort_by: 'updated_at', sort_order: 'asc', }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets.json?${expectedParams}`, { + redirect: 'manual', + headers, + }); }); it('should search tickets by requester email', async () => { @@ -69,7 +75,10 @@ describe('createGetTicketsTool', () => { sort_by: 'created_at', sort_order: 'desc', }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/search.json?${expectedParams}`, { headers }); + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/search.json?${expectedParams}`, { + redirect: 'manual', + headers, + }); expect(result).toBe(JSON.stringify(mockResponse)); }); @@ -80,7 +89,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=submitter%3Asubmitter%40example.com+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -91,7 +100,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=cc%3Acc%40example.com+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -102,7 +111,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=assignee%3Aagent%40example.com+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -113,7 +122,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=status%3Aopen+priority%3Aurgent+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -124,7 +133,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=created%3E2024-01-01+created%3C2024-06-01+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -135,7 +144,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=updated%3E2024-03-01+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -146,7 +155,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=solved%3E2024-05-01+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -157,7 +166,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=description%3A%22login+error%22+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -168,7 +177,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=tags%3Avip+tags%3Abilling+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -179,7 +188,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=subject%3Arefund+%22payment+issue%22+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -196,7 +205,7 @@ describe('createGetTicketsTool', () => { expect.stringContaining( 'query=requester%3Auser%40example.com+status%3Aopen+priority%3Ahigh+type%3Aticket', ), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -206,6 +215,7 @@ describe('createGetTicketsTool', () => { await tool.invoke({ ticket_type: 'incident' }); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('query=type%3Aincident'), { + redirect: 'manual', headers, }); expect(fetch).not.toHaveBeenCalledWith( @@ -221,7 +231,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=group%3Asupport+brand%3Aacme+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -234,7 +244,7 @@ describe('createGetTicketsTool', () => { expect.stringContaining( 'query=group%3A%22tier+1+support%22+brand%3Aacme+subject%3A%22billing+issue%22+type%3Aticket', ), - { headers }, + { redirect: 'manual', headers }, ); }); @@ -245,7 +255,7 @@ describe('createGetTicketsTool', () => { expect(fetch).toHaveBeenCalledWith( expect.stringContaining('query=status%3Aopen+type%3Aticket'), - { headers }, + { redirect: 'manual', headers }, ); }); diff --git a/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts index fd47e14cf9..4a67c97da8 100644 --- a/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/tools/update-ticket.test.ts @@ -34,6 +34,7 @@ describe('createUpdateTicketTool', () => { const result = await tool.invoke({ ticket_id: 7, status: 'solved' }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/7.json`, { + redirect: 'manual', method: 'PUT', headers, body: JSON.stringify({ ticket: { status: 'solved' } }), @@ -57,6 +58,7 @@ describe('createUpdateTicketTool', () => { }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/7.json`, { + redirect: 'manual', method: 'PUT', headers, body: JSON.stringify({ @@ -80,6 +82,7 @@ describe('createUpdateTicketTool', () => { await tool.invoke({ ticket_id: 7 }); expect(fetch).toHaveBeenCalledWith(`${baseUrl}/tickets/7.json`, { + redirect: 'manual', method: 'PUT', headers, body: JSON.stringify({ ticket: {} }), diff --git a/packages/ai-proxy/test/integrations/zendesk/utils.test.ts b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts index a083aa6709..93945d8b81 100644 --- a/packages/ai-proxy/test/integrations/zendesk/utils.test.ts +++ b/packages/ai-proxy/test/integrations/zendesk/utils.test.ts @@ -1,4 +1,4 @@ -import { McpConnectionError } from '../../../src/errors'; +import { AIBadRequestError, McpConnectionError } from '../../../src/errors'; import { assertResponseOk, getZendeskConfig, @@ -21,6 +21,37 @@ describe('zendesk/utils', () => { }, }); }); + + it.each(['my-company', 'Acme1', 'a', 'a'.repeat(63)])( + 'should accept the subdomain %s', + subdomain => { + const { baseUrl } = getZendeskConfig({ subdomain, email: 'a@b.c', apiToken: 't' }); + + expect(baseUrl).toBe(`https://${subdomain}.zendesk.com/api/v2`); + }, + ); + + it.each([ + 'evil.example/x#', + 'evil.example?', + 'mycompany.zendesk.com', + 'user@evil.example', + 'my company', + '', + 'a'.repeat(64), + ])('should reject the subdomain %j', subdomain => { + expect(() => getZendeskConfig({ subdomain, email: 'a@b.c', apiToken: 't' })).toThrow( + new AIBadRequestError(`Invalid Zendesk subdomain: ${JSON.stringify(subdomain)}`), + ); + }); + + it('should reject a subdomain that is not a string', () => { + const config = { subdomain: 42 as unknown as string, email: 'a@b.c', apiToken: 't' }; + + expect(() => getZendeskConfig(config)).toThrow( + new AIBadRequestError('Invalid Zendesk subdomain: 42'), + ); + }); }); describe('assertResponseOk', () => { @@ -117,6 +148,15 @@ describe('zendesk/utils', () => { ); }); + it('should send no request when the subdomain is invalid', async () => { + jest.spyOn(global, 'fetch'); + + await expect( + validateZendeskConfig({ ...config, subdomain: 'evil.example/x#' }), + ).rejects.toThrow(AIBadRequestError); + expect(fetch).not.toHaveBeenCalled(); + }); + it('should throw McpConnectionError using error.title as fallback', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false,