From 14abca9639c02c43014a2a3db6231e20fe86a83c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 3 Oct 2026 12:25:42 -0700 Subject: [PATCH 1/2] fix(tools): reject dot path segments in tool request URLs --- apps/sim/tools/request-transport.test.ts | 53 +++++++++++++++++++++--- apps/sim/tools/request-transport.ts | 2 + apps/sim/tools/url-path.ts | 25 +++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/sim/tools/request-transport.test.ts b/apps/sim/tools/request-transport.test.ts index 49645c56935..bc17c1c0514 100644 --- a/apps/sim/tools/request-transport.test.ts +++ b/apps/sim/tools/request-transport.test.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { describe, expect, it, vi } from 'vitest' import { isInternalToolOperationRegistered } from '@/lib/internal/tool-operations/registry.server' import { requestTool } from '@/tools/http/request' @@ -32,6 +33,7 @@ const PROBE_FILE = { mimeType: 'text/plain', data: 'data:text/plain;base64,cHJvYmU=', } as const +const DOT_SEGMENT_ERROR = 'Tool request URL cannot contain "." or ".." path segments' const EXCEL_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' function createSchemaProbeParams( @@ -129,6 +131,37 @@ describe('external request transport', () => { ).toBe('https://example.com') }) + it.each([ + 'https://api.example.com/v0/inboxes/inbox_1/drafts/..', + 'https://api.example.com/v0/inboxes/inbox_1/drafts/../../../v0/inboxes/other', + 'https://api.example.com/v0/inboxes/inbox_1/drafts/.', + 'https://api.example.com/v0/inboxes/inbox_1/drafts/%2e%2E', + 'https://api.example.com/v0/inboxes/inbox_1/drafts/.%2e?force=true', + 'https://api.example.com/v0/inboxes/inbox_1/drafts/.\t.', + 'https://api.example.com/v0/inboxes/inbox_1\\drafts\\..', + ])('rejects a URL whose path resolves a dot segment: %s', (url) => { + expect(() => + prepareToolRequest( + createRequestTool(() => url), + {} + ) + ).toThrow(DOT_SEGMENT_ERROR) + }) + + it.each([ + 'https://my-app.vercel.app/v1/domains/example.com', + 'https://api.example.com/v1/files/..foo/foo../.env', + 'https://api.example.com/v1/search?path=../x#..', + 'https://api.example.com/', + ])('allows dots that are not whole path segments: %s', (url) => { + expect( + prepareToolRequest( + createRequestTool(() => url), + {} + ).url + ).toBe(url) + }) + it.each([ ['http_request', requestTool, { url: '/api/auth/oauth/token', method: 'GET' }], ['webhook_request', webhookRequestTool, { url: '/api/auth/oauth/token', body: {} }], @@ -169,12 +202,12 @@ describe('dynamic external request registry invariant', () => { if (typeof urlBuilder !== 'function') throw new Error(`${toolId} must have a dynamic URL`) const observations: string[] = [] const scenarios = [ - createSchemaProbeParams(tool, false), - createSchemaProbeParams(tool, true), - createSchemaProbeParams(tool, true, true), + { params: createSchemaProbeParams(tool, false), adversarial: false }, + { params: createSchemaProbeParams(tool, true), adversarial: false }, + { params: createSchemaProbeParams(tool, true, true), adversarial: true }, ] - for (const params of scenarios) { + for (const { params, adversarial } of scenarios) { let url: string try { url = urlBuilder(params as never) @@ -188,12 +221,20 @@ describe('dynamic external request registry invariant', () => { isAbsoluteHttpUrl(url), `${toolId} resolved ${url} outside the external HTTP transport` ).toBe(true) - expect(() => + const prepare = () => prepareToolRequest( createRequestTool(() => url), {} ) - ).not.toThrow() + if (!adversarial) { + expect(prepare, `${toolId} rejected ${url}`).not.toThrow() + continue + } + try { + prepare() + } catch (error) { + expect(getErrorMessage(error), `${toolId} rejected ${url}`).toBe(DOT_SEGMENT_ERROR) + } } if (observations.length === 0) continue diff --git a/apps/sim/tools/request-transport.ts b/apps/sim/tools/request-transport.ts index 9f52a09f4be..0dfb6543654 100644 --- a/apps/sim/tools/request-transport.ts +++ b/apps/sim/tools/request-transport.ts @@ -10,6 +10,7 @@ import { type ToolConfig, type ToolDefinition, } from '@/tools/types' +import { assertNoDotPathSegments } from '@/tools/url-path' const MODEL_INPUT_PROJECTION_ERROR_MESSAGE = 'Model input could not be safely projected' const PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE = @@ -232,6 +233,7 @@ function assertExternalRequestUrl(url: string): void { if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { throw new Error(EXTERNAL_REQUEST_URL_ERROR_MESSAGE) } + assertNoDotPathSegments(url) } /** Materializes one external HTTP request after enforcing the model-input boundary. */ diff --git a/apps/sim/tools/url-path.ts b/apps/sim/tools/url-path.ts index 83ef707172f..568f6a30a02 100644 --- a/apps/sim/tools/url-path.ts +++ b/apps/sim/tools/url-path.ts @@ -186,3 +186,28 @@ export function safeUrlPathSegment(value: string | number | bigint, paramName: s return encodeSegment(trimmed, paramName) } + +/** Matches a scheme and authority, which end at the first `/`, `\`, `?`, or `#` in a special-scheme URL. */ +const SCHEME_AND_AUTHORITY = /^[a-z][a-z\d+.-]*:[\\/]*[^\\/?#]*/i + +/** + * Rejects a request URL whose path carries a dot segment the WHATWG parser + * would resolve away. Mirrors the parser: tabs and newlines are stripped, + * `\` separates segments like `/`, and `%2e` counts as a dot. + * + * @throws If the path contains a `.` or `..` segment in any spelling. + */ +export function assertNoDotPathSegments(url: string): void { + const path = url + .replace(/[\t\n\r]/g, '') + .trim() + .replace(SCHEME_AND_AUTHORITY, '') + .split(/[?#]/, 1)[0] + const hasDotSegment = path.split(/[\\/]/).some((segment) => { + const decoded = segment.replace(/%2e/gi, '.') + return decoded === '.' || decoded === '..' + }) + if (hasDotSegment) { + throw new Error('Tool request URL cannot contain "." or ".." path segments') + } +} From 0b47551e2dd45ae2c4083af4937cddb866caf0e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 3 Oct 2026 12:34:31 -0700 Subject: [PATCH 2/2] fix(tools): match URL parser boundary stripping in dot-segment check --- apps/sim/tools/request-transport.test.ts | 28 ++++++++++++++---------- apps/sim/tools/url-path.ts | 7 +++--- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/sim/tools/request-transport.test.ts b/apps/sim/tools/request-transport.test.ts index bc17c1c0514..0b27336edbd 100644 --- a/apps/sim/tools/request-transport.test.ts +++ b/apps/sim/tools/request-transport.test.ts @@ -1,4 +1,3 @@ -import { getErrorMessage } from '@sim/utils/errors' import { describe, expect, it, vi } from 'vitest' import { isInternalToolOperationRegistered } from '@/lib/internal/tool-operations/registry.server' import { requestTool } from '@/tools/http/request' @@ -81,6 +80,12 @@ function isAbsoluteHttpUrl(url: string): boolean { } } +function hasDotDotPathSegment(url: string): boolean { + const pathStart = url.indexOf('/', url.indexOf('//') + 2) + if (pathStart === -1) return false + return url.slice(pathStart).split(/[?#]/)[0].split('/').includes('..') +} + function createRequestTool( url: string | ((params: Record) => string) ): ToolConfig { @@ -139,6 +144,8 @@ describe('external request transport', () => { 'https://api.example.com/v0/inboxes/inbox_1/drafts/.%2e?force=true', 'https://api.example.com/v0/inboxes/inbox_1/drafts/.\t.', 'https://api.example.com/v0/inboxes/inbox_1\\drafts\\..', + 'https://api.example.com/v0/inboxes/inbox_1/drafts/..\u0001', + ' https://api.example.com/v0/inboxes/inbox_1/drafts/..\u0000 ', ])('rejects a URL whose path resolves a dot segment: %s', (url) => { expect(() => prepareToolRequest( @@ -153,6 +160,7 @@ describe('external request transport', () => { 'https://api.example.com/v1/files/..foo/foo../.env', 'https://api.example.com/v1/search?path=../x#..', 'https://api.example.com/', + 'https://api.example.com/v1/files/..\u00a0', ])('allows dots that are not whole path segments: %s', (url) => { expect( prepareToolRequest( @@ -202,12 +210,12 @@ describe('dynamic external request registry invariant', () => { if (typeof urlBuilder !== 'function') throw new Error(`${toolId} must have a dynamic URL`) const observations: string[] = [] const scenarios = [ - { params: createSchemaProbeParams(tool, false), adversarial: false }, - { params: createSchemaProbeParams(tool, true), adversarial: false }, - { params: createSchemaProbeParams(tool, true, true), adversarial: true }, + createSchemaProbeParams(tool, false), + createSchemaProbeParams(tool, true), + createSchemaProbeParams(tool, true, true), ] - for (const { params, adversarial } of scenarios) { + for (const params of scenarios) { let url: string try { url = urlBuilder(params as never) @@ -226,14 +234,10 @@ describe('dynamic external request registry invariant', () => { createRequestTool(() => url), {} ) - if (!adversarial) { + if (hasDotDotPathSegment(url)) { + expect(prepare, `${toolId} dispatched ${url}`).toThrow(DOT_SEGMENT_ERROR) + } else { expect(prepare, `${toolId} rejected ${url}`).not.toThrow() - continue - } - try { - prepare() - } catch (error) { - expect(getErrorMessage(error), `${toolId} rejected ${url}`).toBe(DOT_SEGMENT_ERROR) } } diff --git a/apps/sim/tools/url-path.ts b/apps/sim/tools/url-path.ts index 568f6a30a02..08bf1663c18 100644 --- a/apps/sim/tools/url-path.ts +++ b/apps/sim/tools/url-path.ts @@ -192,15 +192,16 @@ const SCHEME_AND_AUTHORITY = /^[a-z][a-z\d+.-]*:[\\/]*[^\\/?#]*/i /** * Rejects a request URL whose path carries a dot segment the WHATWG parser - * would resolve away. Mirrors the parser: tabs and newlines are stripped, - * `\` separates segments like `/`, and `%2e` counts as a dot. + * would resolve away. Mirrors the parser: boundary C0 controls and spaces, + * then tabs and newlines, are stripped, `\` separates segments like `/`, and + * `%2e` counts as a dot. * * @throws If the path contains a `.` or `..` segment in any spelling. */ export function assertNoDotPathSegments(url: string): void { const path = url + .replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '') .replace(/[\t\n\r]/g, '') - .trim() .replace(SCHEME_AND_AUTHORITY, '') .split(/[?#]/, 1)[0] const hasDotSegment = path.split(/[\\/]/).some((segment) => {