Skip to content

Commit 825e7e7

Browse files
authored
fix(tools): reject dot path segments in tool request URLs (#8604)
* fix(tools): reject dot path segments in tool request URLs * fix(tools): match URL parser boundary stripping in dot-segment check
1 parent 86fcaf6 commit 825e7e7

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

‎apps/sim/tools/request-transport.test.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const PROBE_FILE = {
3232
mimeType: 'text/plain',
3333
data: 'data:text/plain;base64,cHJvYmU=',
3434
} as const
35+
const DOT_SEGMENT_ERROR = 'Tool request URL cannot contain "." or ".." path segments'
3536
const EXCEL_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
3637

3738
function createSchemaProbeParams(
@@ -79,6 +80,12 @@ function isAbsoluteHttpUrl(url: string): boolean {
7980
}
8081
}
8182

83+
function hasDotDotPathSegment(url: string): boolean {
84+
const pathStart = url.indexOf('/', url.indexOf('//') + 2)
85+
if (pathStart === -1) return false
86+
return url.slice(pathStart).split(/[?#]/)[0].split('/').includes('..')
87+
}
88+
8289
function createRequestTool(
8390
url: string | ((params: Record<string, unknown>) => string)
8491
): ToolConfig {
@@ -129,6 +136,40 @@ describe('external request transport', () => {
129136
).toBe('https://example.com')
130137
})
131138

139+
it.each([
140+
'https://api.example.com/v0/inboxes/inbox_1/drafts/..',
141+
'https://api.example.com/v0/inboxes/inbox_1/drafts/../../../v0/inboxes/other',
142+
'https://api.example.com/v0/inboxes/inbox_1/drafts/.',
143+
'https://api.example.com/v0/inboxes/inbox_1/drafts/%2e%2E',
144+
'https://api.example.com/v0/inboxes/inbox_1/drafts/.%2e?force=true',
145+
'https://api.example.com/v0/inboxes/inbox_1/drafts/.\t.',
146+
'https://api.example.com/v0/inboxes/inbox_1\\drafts\\..',
147+
'https://api.example.com/v0/inboxes/inbox_1/drafts/..\u0001',
148+
' https://api.example.com/v0/inboxes/inbox_1/drafts/..\u0000 ',
149+
])('rejects a URL whose path resolves a dot segment: %s', (url) => {
150+
expect(() =>
151+
prepareToolRequest(
152+
createRequestTool(() => url),
153+
{}
154+
)
155+
).toThrow(DOT_SEGMENT_ERROR)
156+
})
157+
158+
it.each([
159+
'https://my-app.vercel.app/v1/domains/example.com',
160+
'https://api.example.com/v1/files/..foo/foo../.env',
161+
'https://api.example.com/v1/search?path=../x#..',
162+
'https://api.example.com/',
163+
'https://api.example.com/v1/files/..\u00a0',
164+
])('allows dots that are not whole path segments: %s', (url) => {
165+
expect(
166+
prepareToolRequest(
167+
createRequestTool(() => url),
168+
{}
169+
).url
170+
).toBe(url)
171+
})
172+
132173
it.each([
133174
['http_request', requestTool, { url: '/api/auth/oauth/token', method: 'GET' }],
134175
['webhook_request', webhookRequestTool, { url: '/api/auth/oauth/token', body: {} }],
@@ -188,12 +229,16 @@ describe('dynamic external request registry invariant', () => {
188229
isAbsoluteHttpUrl(url),
189230
`${toolId} resolved ${url} outside the external HTTP transport`
190231
).toBe(true)
191-
expect(() =>
232+
const prepare = () =>
192233
prepareToolRequest(
193234
createRequestTool(() => url),
194235
{}
195236
)
196-
).not.toThrow()
237+
if (hasDotDotPathSegment(url)) {
238+
expect(prepare, `${toolId} dispatched ${url}`).toThrow(DOT_SEGMENT_ERROR)
239+
} else {
240+
expect(prepare, `${toolId} rejected ${url}`).not.toThrow()
241+
}
197242
}
198243

199244
if (observations.length === 0) continue

‎apps/sim/tools/request-transport.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type ToolConfig,
1111
type ToolDefinition,
1212
} from '@/tools/types'
13+
import { assertNoDotPathSegments } from '@/tools/url-path'
1314

1415
const MODEL_INPUT_PROJECTION_ERROR_MESSAGE = 'Model input could not be safely projected'
1516
const PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE =
@@ -232,6 +233,7 @@ function assertExternalRequestUrl(url: string): void {
232233
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
233234
throw new Error(EXTERNAL_REQUEST_URL_ERROR_MESSAGE)
234235
}
236+
assertNoDotPathSegments(url)
235237
}
236238

237239
/** Materializes one external HTTP request after enforcing the model-input boundary. */

‎apps/sim/tools/url-path.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,29 @@ export function safeUrlPathSegment(value: string | number | bigint, paramName: s
186186

187187
return encodeSegment(trimmed, paramName)
188188
}
189+
190+
/** Matches a scheme and authority, which end at the first `/`, `\`, `?`, or `#` in a special-scheme URL. */
191+
const SCHEME_AND_AUTHORITY = /^[a-z][a-z\d+.-]*:[\\/]*[^\\/?#]*/i
192+
193+
/**
194+
* Rejects a request URL whose path carries a dot segment the WHATWG parser
195+
* would resolve away. Mirrors the parser: boundary C0 controls and spaces,
196+
* then tabs and newlines, are stripped, `\` separates segments like `/`, and
197+
* `%2e` counts as a dot.
198+
*
199+
* @throws If the path contains a `.` or `..` segment in any spelling.
200+
*/
201+
export function assertNoDotPathSegments(url: string): void {
202+
const path = url
203+
.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '')
204+
.replace(/[\t\n\r]/g, '')
205+
.replace(SCHEME_AND_AUTHORITY, '')
206+
.split(/[?#]/, 1)[0]
207+
const hasDotSegment = path.split(/[\\/]/).some((segment) => {
208+
const decoded = segment.replace(/%2e/gi, '.')
209+
return decoded === '.' || decoded === '..'
210+
})
211+
if (hasDotSegment) {
212+
throw new Error('Tool request URL cannot contain "." or ".." path segments')
213+
}
214+
}

0 commit comments

Comments
 (0)