diff --git a/apps/sim/tools/clerk/add_organization_member.ts b/apps/sim/tools/clerk/add_organization_member.ts index fdf55734496..d444d627003 100644 --- a/apps/sim/tools/clerk/add_organization_member.ts +++ b/apps/sim/tools/clerk/add_organization_member.ts @@ -6,6 +6,7 @@ import type { ClerkOrganizationMembership, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkAddOrganizationMember') @@ -47,7 +48,7 @@ export const clerkAddOrganizationMemberTool: ToolConfig< request: { url: (params) => - `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships`, + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}/memberships`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/ban_user.ts b/apps/sim/tools/clerk/ban_user.ts index be902a68758..5717c3f90e7 100644 --- a/apps/sim/tools/clerk/ban_user.ts +++ b/apps/sim/tools/clerk/ban_user.ts @@ -6,6 +6,7 @@ import type { ClerkUser, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkBanUser') @@ -31,7 +32,8 @@ export const clerkBanUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}/ban`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}/ban`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/create_organization_invitation.ts b/apps/sim/tools/clerk/create_organization_invitation.ts index cc33019257c..78f45b4726d 100644 --- a/apps/sim/tools/clerk/create_organization_invitation.ts +++ b/apps/sim/tools/clerk/create_organization_invitation.ts @@ -6,6 +6,7 @@ import type { ClerkOrganizationInvitation, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkCreateOrganizationInvitation') @@ -83,7 +84,7 @@ export const clerkCreateOrganizationInvitationTool: ToolConfig< request: { url: (params) => - `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/invitations`, + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}/invitations`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/delete_allowlist_identifier.ts b/apps/sim/tools/clerk/delete_allowlist_identifier.ts index a23606557b5..d16f9b11fc2 100644 --- a/apps/sim/tools/clerk/delete_allowlist_identifier.ts +++ b/apps/sim/tools/clerk/delete_allowlist_identifier.ts @@ -6,6 +6,7 @@ import type { ClerkDeleteResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkDeleteAllowlistIdentifier') @@ -35,7 +36,7 @@ export const clerkDeleteAllowlistIdentifierTool: ToolConfig< request: { url: (params) => - `https://api.clerk.com/v1/allowlist_identifiers/${params.identifierId?.trim()}`, + `https://api.clerk.com/v1/allowlist_identifiers/${safeUrlPathSegment(params.identifierId, 'identifierId')}`, method: 'DELETE', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/delete_blocklist_identifier.ts b/apps/sim/tools/clerk/delete_blocklist_identifier.ts index a915de0e6d1..4935ba53767 100644 --- a/apps/sim/tools/clerk/delete_blocklist_identifier.ts +++ b/apps/sim/tools/clerk/delete_blocklist_identifier.ts @@ -6,6 +6,7 @@ import type { ClerkDeleteResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkDeleteBlocklistIdentifier') @@ -35,7 +36,7 @@ export const clerkDeleteBlocklistIdentifierTool: ToolConfig< request: { url: (params) => - `https://api.clerk.com/v1/blocklist_identifiers/${params.identifierId?.trim()}`, + `https://api.clerk.com/v1/blocklist_identifiers/${safeUrlPathSegment(params.identifierId, 'identifierId')}`, method: 'DELETE', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/delete_organization.ts b/apps/sim/tools/clerk/delete_organization.ts index e14aaca1b40..5b6597a4193 100644 --- a/apps/sim/tools/clerk/delete_organization.ts +++ b/apps/sim/tools/clerk/delete_organization.ts @@ -6,6 +6,7 @@ import type { ClerkDeleteResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkDeleteOrganization') @@ -34,7 +35,8 @@ export const clerkDeleteOrganizationTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}`, method: 'DELETE', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/delete_user.ts b/apps/sim/tools/clerk/delete_user.ts index 94fe3fe992d..67de1da8ea3 100644 --- a/apps/sim/tools/clerk/delete_user.ts +++ b/apps/sim/tools/clerk/delete_user.ts @@ -6,6 +6,7 @@ import type { ClerkDeleteUserResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkDeleteUser') @@ -31,7 +32,8 @@ export const clerkDeleteUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}`, method: 'DELETE', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_jwt_template.ts b/apps/sim/tools/clerk/get_jwt_template.ts index 311e21aa61e..49a66e0b6ad 100644 --- a/apps/sim/tools/clerk/get_jwt_template.ts +++ b/apps/sim/tools/clerk/get_jwt_template.ts @@ -6,6 +6,7 @@ import type { ClerkJwtTemplate, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkGetJwtTemplate') @@ -34,7 +35,8 @@ export const clerkGetJwtTemplateTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/jwt_templates/${params.templateId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/jwt_templates/${safeUrlPathSegment(params.templateId, 'templateId')}`, method: 'GET', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_organization.ts b/apps/sim/tools/clerk/get_organization.ts index 14b3f1a6ffb..88d4e51c0f2 100644 --- a/apps/sim/tools/clerk/get_organization.ts +++ b/apps/sim/tools/clerk/get_organization.ts @@ -6,6 +6,7 @@ import type { ClerkOrganization, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkGetOrganization') @@ -35,7 +36,8 @@ export const clerkGetOrganizationTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}`, method: 'GET', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_session.ts b/apps/sim/tools/clerk/get_session.ts index ed271e09d16..066bfb1ec91 100644 --- a/apps/sim/tools/clerk/get_session.ts +++ b/apps/sim/tools/clerk/get_session.ts @@ -6,6 +6,7 @@ import type { ClerkSession, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkGetSession') @@ -31,7 +32,8 @@ export const clerkGetSessionTool: ToolConfig `https://api.clerk.com/v1/sessions/${params.sessionId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/sessions/${safeUrlPathSegment(params.sessionId, 'sessionId')}`, method: 'GET', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_user.ts b/apps/sim/tools/clerk/get_user.ts index d956509767c..f8469d1e835 100644 --- a/apps/sim/tools/clerk/get_user.ts +++ b/apps/sim/tools/clerk/get_user.ts @@ -8,6 +8,7 @@ import type { ClerkUser, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkGetUser') @@ -33,7 +34,8 @@ export const clerkGetUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}`, method: 'GET', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_user_oauth_token.ts b/apps/sim/tools/clerk/get_user_oauth_token.ts index 2c6e8602f8f..8032c3dad8f 100644 --- a/apps/sim/tools/clerk/get_user_oauth_token.ts +++ b/apps/sim/tools/clerk/get_user_oauth_token.ts @@ -6,6 +6,7 @@ import type { ClerkOAuthAccessToken, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkGetUserOauthToken') @@ -43,8 +44,8 @@ export const clerkGetUserOauthTokenTool: ToolConfig< request: { url: (params) => { - const providerSlug = params.provider?.trim().replace(/^oauth_/, '') - return `https://api.clerk.com/v1/users/${params.userId?.trim()}/oauth_access_tokens/oauth_${providerSlug}` + const providerSlug = safeUrlPathSegment(params.provider, 'provider').replace(/^oauth_/, '') + return `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}/oauth_access_tokens/oauth_${providerSlug}` }, method: 'GET', headers: (params) => { diff --git a/apps/sim/tools/clerk/list_organization_invitations.ts b/apps/sim/tools/clerk/list_organization_invitations.ts index 9aa02429a45..d1f0bf72c1e 100644 --- a/apps/sim/tools/clerk/list_organization_invitations.ts +++ b/apps/sim/tools/clerk/list_organization_invitations.ts @@ -6,6 +6,7 @@ import type { ClerkOrganizationInvitation, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkListOrganizationInvitations') @@ -74,7 +75,7 @@ export const clerkListOrganizationInvitationsTool: ToolConfig< if (params.offset) queryParams.append('offset', params.offset.toString()) const queryString = queryParams.toString() - const base = `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/invitations` + const base = `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}/invitations` return queryString ? `${base}?${queryString}` : base }, method: 'GET', diff --git a/apps/sim/tools/clerk/list_organization_memberships.ts b/apps/sim/tools/clerk/list_organization_memberships.ts index 5c1bac5f3b9..643c21aef00 100644 --- a/apps/sim/tools/clerk/list_organization_memberships.ts +++ b/apps/sim/tools/clerk/list_organization_memberships.ts @@ -6,6 +6,7 @@ import type { ClerkOrganizationMembership, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkListOrganizationMemberships') @@ -71,7 +72,7 @@ export const clerkListOrganizationMembershipsTool: ToolConfig< } const queryString = queryParams.toString() - const base = `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships` + const base = `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}/memberships` return queryString ? `${base}?${queryString}` : base }, method: 'GET', diff --git a/apps/sim/tools/clerk/lock_user.ts b/apps/sim/tools/clerk/lock_user.ts index 290cbc496e4..abc96beb072 100644 --- a/apps/sim/tools/clerk/lock_user.ts +++ b/apps/sim/tools/clerk/lock_user.ts @@ -6,6 +6,7 @@ import type { ClerkUser, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkLockUser') @@ -31,7 +32,8 @@ export const clerkLockUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}/lock`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}/lock`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/path_safety.test.ts b/apps/sim/tools/clerk/path_safety.test.ts new file mode 100644 index 00000000000..bb499d8a17d --- /dev/null +++ b/apps/sim/tools/clerk/path_safety.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + * + * Guards every Clerk tool against path traversal through an LLM-writable ID + * that gets interpolated into the request path. + * + * This is the highest-value surface of the three services hardened together: + * the Clerk credential is a backend API key carrying full user-management + * authority. The affected IDs — `userId`, `organizationId`, `sessionId`, + * `identifierId`, `templateId`, `actorTokenId`, and the OAuth `provider` slug — + * are `visibility: 'user-or-llm'`, so prompt injection controls them. + * Interpolating one raw let a value like `../../users/user_victim` escape its + * API prefix once `fetch` normalized the URL, re-aiming the request — and that + * secret key — at an arbitrary Clerk resource, including on the delete, ban, + * and lock routes. `assertRequestUrlMatchesTrust` in + * `tools/request-transport.ts` only applies its canonicalization guard to + * internal `/api/` routes, so nothing downstream catches this. + * + * Wrapping the ID in `encodeURIComponent` is NOT enough, which is why the + * vector list below includes the bare `.` and `..` segments: both are made of + * unreserved characters, so they survive encoding untouched and the URL parser + * then removes them as dot segments, popping one path segment off a fixed host. + * Every assertion here resolves the built URL with `new URL(...)` — the same + * normalization `fetch` performs — rather than string-matching the template + * output, because string matching is exactly what let this through. + * + * The suite enumerates the tool barrel and then, per tool, every declared + * parameter that reaches a path segment, fuzzing **one parameter at a time** + * while the others hold a safe value. A new tool — or a new ID parameter on an + * existing tool — is therefore covered without anyone remembering to register + * it, and a still-guarded sibling parameter cannot mask an unguarded one by + * throwing first. + * + * Discovery inspects what `request.url` returns. That is total for this + * service: no Clerk tool issues a `fetch` of its own from `transformResponse`, + * so `request.url` is the only place a URL is built. Jira does build a second + * URL there, and its suite carries an extra block for it — if a Clerk tool ever + * grows one, this file needs the same. + */ +import { describe, expect, it } from 'vitest' +import * as clerkTools from '@/tools/clerk/index' + +/** + * The bare `.` and `..` entries are the whole point: their omission is why an + * `encodeURIComponent`-only fix looks correct while the hole stays live. + */ +const TRAVERSAL_IDS = [ + '..', + '.', + ' .. ', + '../../users/user_victim', + '..%2f..%2fusers/user_victim', + 'user_abc/../../../organizations/org_victim', + 'user_abc?injectedProbe=attacker', + 'user_abc#fragment', + 'user_abc/sessions/../../../users', + '\\..\\..', +] as const + +/** Values a real user legitimately supplies; none may be rejected or altered. */ +const LEGITIMATE_IDS = [ + 'user_2abcDEF', + 'user_2NNEqL2nrIRdJ194ndJqAHwEfxC', + 'org_2abcDEF', + 'sess_2abcDEF', + 'alid_2abcDEF', + 'jtmp_2abcDEF', + 'google', + 'v1.2.3', + '..foo', + 'foo..', +] as const + +const SAFE_ID = 'SAFEID' + +/** Sentinel for the one parameter under test, so its slots are identifiable. */ +const PROBE_ID = 'PROBEID' + +const BASE_ORIGIN = 'https://api.clerk.com' + +/** Every Clerk backend route this integration calls lives under this prefix. */ +const BASE_PATH = '/v1/' + +/** Supplied by the platform, never by the model. */ +const FIXED_PARAMS: Record = { secretKey: 'sk_test_token' } + +/** + * The slice of a tool this suite drives. Narrowing to it — rather than reaching + * for `ToolConfig` and an `as any` at the call site — keeps the + * harness typed end to end while staying agnostic about each tool's own param + * and response generics, which differ per tool and are irrelevant here. + */ +type ToolParams = Record + +type UrlBuilder = (params: ToolParams) => string + +interface ParamDefinition { + readonly type?: string +} + +interface PathBuildingTool { + readonly id: string + readonly params: Readonly> + readonly buildUrl: UrlBuilder +} + +/** + * Narrows a barrel export to a Clerk tool that builds its URL from params. + * Anything else — a type-only re-export, a tool with a static URL — yields + * `null` and drops out of the suite. + */ +function asPathBuildingTool(value: unknown): PathBuildingTool | null { + if (typeof value !== 'object' || value === null) return null + + const candidate = value as { id?: unknown; params?: unknown; request?: unknown } + if (typeof candidate.id !== 'string' || !candidate.id.startsWith('clerk_')) return null + + const request = candidate.request as { url?: unknown } | undefined + if (typeof request?.url !== 'function') return null + + return { + id: candidate.id, + params: (candidate.params ?? {}) as Record, + buildUrl: request.url as UrlBuilder, + } +} + +/** + * Fills every declared parameter with a type-appropriate safe value, then + * overrides the single parameter under test. + */ +function buildParams(tool: PathBuildingTool, paramName: string, value: string): ToolParams { + const params: ToolParams = {} + for (const [name, definition] of Object.entries(tool.params)) { + if (definition.type === 'json' || definition.type === 'array') { + params[name] = [] + } else if (definition.type === 'number') { + params[name] = 1 + } else if (definition.type === 'boolean') { + params[name] = false + } else { + params[name] = SAFE_ID + } + } + Object.assign(params, FIXED_PARAMS) + params[paramName] = value + return params +} + +function buildUrl(tool: PathBuildingTool, paramName: string, value: string): URL { + return new URL(tool.buildUrl(buildParams(tool, paramName, value))) +} + +function segmentsOf(tool: PathBuildingTool, paramName: string, value: string): string[] { + return buildUrl(tool, paramName, value).pathname.split('/') +} + +/** Every (tool, parameter) pair whose value lands in a URL path segment. */ +const PATH_PARAMS = Object.values(clerkTools) + .map(asPathBuildingTool) + .filter((tool): tool is PathBuildingTool => tool !== null) + .flatMap((tool) => + Object.keys(tool.params) + .filter((name) => !(name in FIXED_PARAMS)) + .filter((name) => { + try { + return buildUrl(tool, name, PROBE_ID).pathname.includes(PROBE_ID) + } catch { + return false + } + }) + .map((name) => ({ label: `${tool.id} :: ${name}`, tool, paramName: name })) + ) + +describe('clerk path-ID traversal safety', () => { + it('covers every Clerk parameter that reaches a URL path segment', () => { + expect(PATH_PARAMS.length).toBeGreaterThanOrEqual(26) + }) + + describe.each(PATH_PARAMS)('$label', ({ tool, paramName }) => { + const baseline = segmentsOf(tool, paramName, PROBE_ID) + + it('stays under the Clerk backend API prefix', () => { + expect(buildUrl(tool, paramName, PROBE_ID).pathname.startsWith(BASE_PATH)).toBe(true) + }) + + it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (value) => { + let url: URL + try { + url = buildUrl(tool, paramName, value) + } catch { + return + } + + expect(url.origin).toBe(BASE_ORIGIN) + expect(url.pathname.startsWith(BASE_PATH)).toBe(true) + + const actual = url.pathname.split('/') + expect(actual).toHaveLength(baseline.length) + baseline.forEach((segment, index) => { + if (segment.includes(PROBE_ID)) return + expect(actual[index]).toBe(segment) + }) + }) + + it.each(['..', '.'] as const)('rejects the bare %j segment by name', (value) => { + expect(() => buildUrl(tool, paramName, value)).toThrow(new RegExp(paramName)) + }) + + it.each(LEGITIMATE_IDS)('passes %j through unchanged', (value) => { + const actual = segmentsOf(tool, paramName, value) + + expect(actual).toHaveLength(baseline.length) + baseline.forEach((segment, index) => { + expect(actual[index]).toBe(segment.split(PROBE_ID).join(value)) + }) + }) + + it('trims surrounding whitespace without altering the id', () => { + expect(segmentsOf(tool, paramName, ` ${PROBE_ID} `)).toEqual(baseline) + }) + + it('does not let the id inject a query parameter', () => { + const url = buildUrl(tool, paramName, `${PROBE_ID}?injectedProbe=attacker`) + + expect(url.searchParams.get('injectedProbe')).toBeNull() + }) + }) +}) diff --git a/apps/sim/tools/clerk/remove_organization_member.ts b/apps/sim/tools/clerk/remove_organization_member.ts index 9b35396661d..4acc3cb0080 100644 --- a/apps/sim/tools/clerk/remove_organization_member.ts +++ b/apps/sim/tools/clerk/remove_organization_member.ts @@ -6,6 +6,7 @@ import type { ClerkRemoveOrganizationMemberResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkRemoveOrganizationMember') @@ -41,7 +42,7 @@ export const clerkRemoveOrganizationMemberTool: ToolConfig< request: { url: (params) => - `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships/${params.userId?.trim()}`, + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}/memberships/${safeUrlPathSegment(params.userId, 'userId')}`, method: 'DELETE', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/revoke_actor_token.ts b/apps/sim/tools/clerk/revoke_actor_token.ts index 2457dbcb330..494e52fca7a 100644 --- a/apps/sim/tools/clerk/revoke_actor_token.ts +++ b/apps/sim/tools/clerk/revoke_actor_token.ts @@ -6,6 +6,7 @@ import type { ClerkRevokeActorTokenResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkRevokeActorToken') @@ -34,7 +35,8 @@ export const clerkRevokeActorTokenTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/actor_tokens/${params.actorTokenId?.trim()}/revoke`, + url: (params) => + `https://api.clerk.com/v1/actor_tokens/${safeUrlPathSegment(params.actorTokenId, 'actorTokenId')}/revoke`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/revoke_session.ts b/apps/sim/tools/clerk/revoke_session.ts index 311ead427e8..7c226d85d4a 100644 --- a/apps/sim/tools/clerk/revoke_session.ts +++ b/apps/sim/tools/clerk/revoke_session.ts @@ -6,6 +6,7 @@ import type { ClerkSession, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkRevokeSession') @@ -34,7 +35,8 @@ export const clerkRevokeSessionTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId?.trim()}/revoke`, + url: (params) => + `https://api.clerk.com/v1/sessions/${safeUrlPathSegment(params.sessionId, 'sessionId')}/revoke`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/unban_user.ts b/apps/sim/tools/clerk/unban_user.ts index f025775e1c6..e3e30919b9f 100644 --- a/apps/sim/tools/clerk/unban_user.ts +++ b/apps/sim/tools/clerk/unban_user.ts @@ -6,6 +6,7 @@ import type { ClerkUser, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkUnbanUser') @@ -31,7 +32,8 @@ export const clerkUnbanUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}/unban`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}/unban`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/unlock_user.ts b/apps/sim/tools/clerk/unlock_user.ts index 9c0ef1b8018..d067cfd44a9 100644 --- a/apps/sim/tools/clerk/unlock_user.ts +++ b/apps/sim/tools/clerk/unlock_user.ts @@ -6,6 +6,7 @@ import type { ClerkUser, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkUnlockUser') @@ -31,7 +32,8 @@ export const clerkUnlockUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}/unlock`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}/unlock`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/update_organization.ts b/apps/sim/tools/clerk/update_organization.ts index 201fc650b22..eec4d42eed3 100644 --- a/apps/sim/tools/clerk/update_organization.ts +++ b/apps/sim/tools/clerk/update_organization.ts @@ -6,6 +6,7 @@ import type { ClerkUpdateOrganizationResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkUpdateOrganization') @@ -58,7 +59,8 @@ export const clerkUpdateOrganizationTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}`, method: 'PATCH', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/update_organization_membership.ts b/apps/sim/tools/clerk/update_organization_membership.ts index d7cb8f8ae21..a2eeccb037c 100644 --- a/apps/sim/tools/clerk/update_organization_membership.ts +++ b/apps/sim/tools/clerk/update_organization_membership.ts @@ -6,6 +6,7 @@ import type { ClerkUpdateOrganizationMembershipResponse, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkUpdateOrganizationMembership') @@ -47,7 +48,7 @@ export const clerkUpdateOrganizationMembershipTool: ToolConfig< request: { url: (params) => - `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships/${params.userId?.trim()}`, + `https://api.clerk.com/v1/organizations/${safeUrlPathSegment(params.organizationId, 'organizationId')}/memberships/${safeUrlPathSegment(params.userId, 'userId')}`, method: 'PATCH', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/update_user.ts b/apps/sim/tools/clerk/update_user.ts index 776feef5069..9c75e3c3559 100644 --- a/apps/sim/tools/clerk/update_user.ts +++ b/apps/sim/tools/clerk/update_user.ts @@ -8,6 +8,7 @@ import type { ClerkUser, } from '@/tools/clerk/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('ClerkUpdateUser') @@ -99,7 +100,8 @@ export const clerkUpdateUserTool: ToolConfig `https://api.clerk.com/v1/users/${params.userId?.trim()}`, + url: (params) => + `https://api.clerk.com/v1/users/${safeUrlPathSegment(params.userId, 'userId')}`, method: 'PATCH', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/jira/add_comment.ts b/apps/sim/tools/jira/add_comment.ts index 7d784b39029..4fd71696468 100644 --- a/apps/sim/tools/jira/add_comment.ts +++ b/apps/sim/tools/jira/add_comment.ts @@ -2,6 +2,7 @@ import type { JiraAddCommentParams, JiraAddCommentResponse } from '@/tools/jira/ import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types' import { extractAdfText, getJiraCloudId, toAdf, transformUser } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' /** * Transforms an add comment API response into typed output. @@ -74,7 +75,7 @@ export const jiraAddCommentTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/comment` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/comment` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -99,7 +100,7 @@ export const jiraAddCommentTool: ToolConfig { - const commentUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/comment` + const commentUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/comment` const commentResponse = await fetch(commentUrl, { method: 'POST', headers: { diff --git a/apps/sim/tools/jira/add_watcher.ts b/apps/sim/tools/jira/add_watcher.ts index 65419b59be6..762ad09ef49 100644 --- a/apps/sim/tools/jira/add_watcher.ts +++ b/apps/sim/tools/jira/add_watcher.ts @@ -2,6 +2,7 @@ import type { JiraAddWatcherParams, JiraAddWatcherResponse } from '@/tools/jira/ import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraAddWatcherTool: ToolConfig = { id: 'jira_add_watcher', @@ -51,7 +52,7 @@ export const jiraAddWatcherTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/watchers` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/watchers` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -78,7 +79,7 @@ export const jiraAddWatcherTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/worklog` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/worklog` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -134,7 +135,7 @@ export const jiraAddWorklogTool: ToolConfig { - const worklogUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/worklog` + const worklogUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/worklog` const worklogResponse = await fetch(worklogUrl, { method: 'POST', headers: { diff --git a/apps/sim/tools/jira/assign_issue.ts b/apps/sim/tools/jira/assign_issue.ts index 7312a006883..14bf30ac703 100644 --- a/apps/sim/tools/jira/assign_issue.ts +++ b/apps/sim/tools/jira/assign_issue.ts @@ -2,6 +2,7 @@ import type { JiraAssignIssueParams, JiraAssignIssueResponse } from '@/tools/jir import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' /** * Maps user-provided accountId to the Jira API value. @@ -66,7 +67,7 @@ export const jiraAssignIssueTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/assignee` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/assignee` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -87,7 +88,7 @@ export const jiraAssignIssueTool: ToolConfig { if (!params?.cloudId) { const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) - const assignUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/assignee` + const assignUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/assignee` const assignResponse = await fetch(assignUrl, { method: 'PUT', headers: { diff --git a/apps/sim/tools/jira/bulk_read.ts b/apps/sim/tools/jira/bulk_read.ts index ac6a55add87..36d129efa08 100644 --- a/apps/sim/tools/jira/bulk_read.ts +++ b/apps/sim/tools/jira/bulk_read.ts @@ -3,6 +3,7 @@ import type { JiraRetrieveBulkParams, JiraRetrieveResponseBulk } from '@/tools/j import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { extractAdfText } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraBulkRetrieveTool: ToolConfig = { id: 'jira_bulk_read', @@ -59,7 +60,7 @@ export const jiraBulkRetrieveTool: ToolConfig { const refTrimmed = (ref || '').trim() if (!refTrimmed) return refTrimmed - const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${encodeURIComponent(refTrimmed)}` + const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${safeUrlPathSegment(refTrimmed, 'projectId')}` const resp = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, diff --git a/apps/sim/tools/jira/delete_attachment.ts b/apps/sim/tools/jira/delete_attachment.ts index 62f9b6afbb9..c5bb4fef8ce 100644 --- a/apps/sim/tools/jira/delete_attachment.ts +++ b/apps/sim/tools/jira/delete_attachment.ts @@ -2,6 +2,7 @@ import type { JiraDeleteAttachmentParams, JiraDeleteAttachmentResponse } from '@ import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraDeleteAttachmentTool: ToolConfig< JiraDeleteAttachmentParams, @@ -48,7 +49,7 @@ export const jiraDeleteAttachmentTool: ToolConfig< request: { url: (params: JiraDeleteAttachmentParams) => { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/attachment/${params.attachmentId?.trim() ?? ''}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/attachment/${safeUrlPathSegment(params.attachmentId, 'attachmentId')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -65,7 +66,7 @@ export const jiraDeleteAttachmentTool: ToolConfig< if (!params?.cloudId) { const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) // Make the actual request with the resolved cloudId - const attachmentUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/attachment/${params?.attachmentId?.trim() ?? ''}` + const attachmentUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/attachment/${safeUrlPathSegment(params!.attachmentId, 'attachmentId')}` const attachmentResponse = await fetch(attachmentUrl, { method: 'DELETE', headers: { diff --git a/apps/sim/tools/jira/delete_comment.ts b/apps/sim/tools/jira/delete_comment.ts index 526f38c98ef..2486c8fafb5 100644 --- a/apps/sim/tools/jira/delete_comment.ts +++ b/apps/sim/tools/jira/delete_comment.ts @@ -2,6 +2,7 @@ import type { JiraDeleteCommentParams, JiraDeleteCommentResponse } from '@/tools import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraDeleteCommentTool: ToolConfig = { @@ -52,7 +53,7 @@ export const jiraDeleteCommentTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/comment/${params.commentId?.trim() ?? ''}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/comment/${safeUrlPathSegment(params.commentId, 'commentId')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -69,7 +70,7 @@ export const jiraDeleteCommentTool: ToolConfig = { id: 'jira_delete_issue', @@ -53,7 +54,7 @@ export const jiraDeleteIssueTool: ToolConfig { if (params.cloudId) { const deleteSubtasksParam = params.deleteSubtasks ? '?deleteSubtasks=true' : '' - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}${deleteSubtasksParam}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}${deleteSubtasksParam}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -70,7 +71,7 @@ export const jiraDeleteIssueTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issueLink/${params.linkId?.trim() ?? ''}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issueLink/${safeUrlPathSegment(params.linkId, 'linkId')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -64,7 +65,7 @@ export const jiraDeleteIssueLinkTool: ToolConfig< transformResponse: async (response: Response, params?: JiraDeleteIssueLinkParams) => { if (!params?.cloudId) { const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) - const issueLinkUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issueLink/${params!.linkId?.trim() ?? ''}` + const issueLinkUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issueLink/${safeUrlPathSegment(params!.linkId, 'linkId')}` const issueLinkResponse = await fetch(issueLinkUrl, { method: 'DELETE', headers: { diff --git a/apps/sim/tools/jira/delete_worklog.ts b/apps/sim/tools/jira/delete_worklog.ts index b43faecb00a..0a6a674137e 100644 --- a/apps/sim/tools/jira/delete_worklog.ts +++ b/apps/sim/tools/jira/delete_worklog.ts @@ -2,6 +2,7 @@ import type { JiraDeleteWorklogParams, JiraDeleteWorklogResponse } from '@/tools import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraDeleteWorklogTool: ToolConfig = { @@ -52,7 +53,7 @@ export const jiraDeleteWorklogTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/worklog/${params.worklogId?.trim() ?? ''}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/worklog/${safeUrlPathSegment(params.worklogId, 'worklogId')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -68,7 +69,7 @@ export const jiraDeleteWorklogTool: ToolConfig { if (!params?.cloudId) { const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) - const worklogUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/worklog/${params!.worklogId?.trim() ?? ''}` + const worklogUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/worklog/${safeUrlPathSegment(params!.worklogId, 'worklogId')}` const worklogResponse = await fetch(worklogUrl, { method: 'DELETE', headers: { diff --git a/apps/sim/tools/jira/get_attachments.ts b/apps/sim/tools/jira/get_attachments.ts index 2bcc027d18d..57d21d2d753 100644 --- a/apps/sim/tools/jira/get_attachments.ts +++ b/apps/sim/tools/jira/get_attachments.ts @@ -2,6 +2,7 @@ import type { JiraGetAttachmentsParams, JiraGetAttachmentsResponse } from '@/too import { ATTACHMENT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { downloadJiraAttachments, getJiraCloudId, transformUser } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' /** * Transforms a raw Jira attachment object into typed output. @@ -72,7 +73,7 @@ export const jiraGetAttachmentsTool: ToolConfig< request: { url: (params: JiraGetAttachmentsParams) => { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}?fields=attachment` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}?fields=attachment` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -87,7 +88,7 @@ export const jiraGetAttachmentsTool: ToolConfig< transformResponse: async (response: Response, params?: JiraGetAttachmentsParams) => { const fetchAttachments = async (cloudId: string) => { - const attachmentsUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}?fields=attachment` + const attachmentsUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}?fields=attachment` const attachmentsResponse = await fetch(attachmentsUrl, { method: 'GET', headers: { diff --git a/apps/sim/tools/jira/get_comments.ts b/apps/sim/tools/jira/get_comments.ts index 6e2876f5768..72ff594c31e 100644 --- a/apps/sim/tools/jira/get_comments.ts +++ b/apps/sim/tools/jira/get_comments.ts @@ -2,6 +2,7 @@ import type { JiraGetCommentsParams, JiraGetCommentsResponse } from '@/tools/jir import { COMMENT_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { extractAdfText, getJiraCloudId, transformUser } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' /** * Transforms a raw Jira comment object into typed output. @@ -85,7 +86,7 @@ export const jiraGetCommentsTool: ToolConfig = { diff --git a/apps/sim/tools/jira/get_transitions.ts b/apps/sim/tools/jira/get_transitions.ts index 94f92df44dc..ffa1803e405 100644 --- a/apps/sim/tools/jira/get_transitions.ts +++ b/apps/sim/tools/jira/get_transitions.ts @@ -2,9 +2,10 @@ import type { JiraGetTransitionsParams, JiraGetTransitionsResponse } from '@/too import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' function buildTransitionsUrl(cloudId: string, issueKey: string): string { - return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions` + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(issueKey, 'issueKey')}/transitions` } export const jiraGetTransitionsTool: ToolConfig< diff --git a/apps/sim/tools/jira/get_worklogs.ts b/apps/sim/tools/jira/get_worklogs.ts index c788fe2af2a..c49fb27d3b0 100644 --- a/apps/sim/tools/jira/get_worklogs.ts +++ b/apps/sim/tools/jira/get_worklogs.ts @@ -2,6 +2,7 @@ import type { JiraGetWorklogsParams, JiraGetWorklogsResponse } from '@/tools/jir import { TIMESTAMP_OUTPUT, WORKLOG_ITEM_PROPERTIES } from '@/tools/jira/types' import { extractAdfText, getJiraCloudId, transformUser } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' /** * Transforms a raw Jira worklog object into typed output. @@ -77,7 +78,7 @@ export const jiraGetWorklogsTool: ToolConfig { const startAt = params?.startAt ?? 0 const maxResults = params?.maxResults ?? 50 - const worklogsUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/worklog?startAt=${startAt}&maxResults=${maxResults}` + const worklogsUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/worklog?startAt=${startAt}&maxResults=${maxResults}` const worklogsResponse = await fetch(worklogsUrl, { method: 'GET', headers: { diff --git a/apps/sim/tools/jira/path_safety.test.ts b/apps/sim/tools/jira/path_safety.test.ts new file mode 100644 index 00000000000..cae1db216d6 --- /dev/null +++ b/apps/sim/tools/jira/path_safety.test.ts @@ -0,0 +1,389 @@ +/** + * @vitest-environment node + * + * Guards every Jira tool against path traversal through an LLM-writable ID + * that gets interpolated into the request path. + * + * `issueKey`, `commentId`, `worklogId`, `attachmentId`, `linkId`, and + * `projectId` are `visibility: 'user-or-llm'`, so prompt injection controls + * them. Interpolating one raw let a value like `../../../project/OTHER` escape + * its `/rest/api/3/issue/` prefix once `fetch` normalized the URL, re-aiming + * the request — and the user's Atlassian OAuth token — at a different resource + * on the same site, including on DELETE. `assertRequestUrlMatchesTrust` in + * `tools/request-transport.ts` only applies its canonicalization guard to + * internal `/api/` routes, so nothing downstream catches this. + * + * `cloudId` is deliberately pinned here rather than fuzzed. It is + * `visibility: 'hidden'`, no Jira block subBlock ever writes it, and its only + * real source is `getJiraCloudId` -> `resolveAtlassianCloudId`, which returns a + * UUID from Atlassian's own accessible-resources endpoint. Fuzzing it would + * assert a threat model that does not exist while masking the parameters that + * do carry one. Pinning it also selects the direct-request branch: without a + * `cloudId` every tool returns the discovery URL instead. + * + * Wrapping the ID in `encodeURIComponent` is NOT enough, which is why the + * vector list below includes the bare `.` and `..` segments: both are made of + * unreserved characters, so they survive encoding untouched and the URL parser + * then removes them as dot segments, popping one path segment off a fixed host. + * Every assertion here resolves the built URL with `new URL(...)` — the same + * normalization `fetch` performs — rather than string-matching the template + * output, because string matching is exactly what let this through. + * + * The suite enumerates the tool barrel and then, per tool, every declared + * parameter that reaches a path segment, fuzzing **one parameter at a time** + * while the others hold a safe value. A new tool — or a new ID parameter on an + * existing tool — is therefore covered without anyone remembering to register + * it, and a still-guarded sibling parameter cannot mask an unguarded one by + * throwing first. + * + * Jira builds its URLs in **two** places, and this file has a suite for each. + * `request.url` covers the call made when a `cloudId` is already present; the + * second block at the bottom covers the URL a tool constructs inside + * `transformResponse` after resolving one, which `request.url` cannot expose + * (`jira_bulk_read.projectId` reaches a path segment only that way). The + * fallback block discovers its parameters by running `transformResponse` + * against a stubbed `fetch` and reading back what was requested, so the + * enumeration is total for URLs a tool actually sends — a URL a tool built but + * never fetched would still be invisible to both. + */ +import { describe, expect, it } from 'vitest' +import * as jiraTools from '@/tools/jira/index' + +/** + * The bare `.` and `..` entries are the whole point: their omission is why an + * `encodeURIComponent`-only fix looks correct while the hole stays live. + */ +const TRAVERSAL_IDS = [ + '..', + '.', + ' .. ', + '../../../project/OTHER', + '..%2f..%2f..%2fproject/OTHER', + 'PROJ-123/../../../project/OTHER', + 'PROJ-123?injectedProbe=attacker', + 'PROJ-123#fragment', + 'PROJ-123/comment/../../../myself', + '\\..\\..', +] as const + +/** Values a real user legitimately supplies; none may be rejected or altered. */ +const LEGITIMATE_IDS = [ + 'PROJ-123', + 'ABC-1', + 'MY_PROJECT-4567', + '10001', + 'PROJ', + '..foo', + 'foo..', +] as const + +const SAFE_ID = 'SAFEID' + +/** Sentinel for the one parameter under test, so its slots are identifiable. */ +const PROBE_ID = 'PROBEID' + +const BASE_ORIGIN = 'https://api.atlassian.com' + +const CLOUD_ID = '11111111-2222-4333-8444-555555555555' + +/** Every per-site Jira REST call this integration makes lives under this prefix. */ +const BASE_PATH = `/ex/jira/${CLOUD_ID}/rest/api/3/` + +/** Supplied by the platform, never by the model. */ +const FIXED_PARAMS: Record = { + accessToken: 'token', + domain: 'example.atlassian.net', + cloudId: CLOUD_ID, +} + +/** + * The slice of a tool this suite drives. Narrowing to it — rather than reaching + * for `ToolConfig` and an `as any` at the call site — keeps the + * harness typed end to end while staying agnostic about each tool's own param + * and response generics, which differ per tool and are irrelevant here. + */ +type ToolParams = Record + +type UrlBuilder = (params: ToolParams) => string + +interface ParamDefinition { + readonly type?: string +} + +type TransformResponse = (response: Response, params: ToolParams) => Promise + +interface PathBuildingTool { + readonly id: string + readonly params: Readonly> + readonly buildUrl: UrlBuilder + /** Present on the Jira tools that re-issue the call after resolving a cloudId. */ + readonly transformResponse: TransformResponse | null +} + +/** + * Narrows a barrel export to a Jira tool that builds its URL from params. + * Anything else — a type-only re-export, a tool with a static URL — yields + * `null` and drops out of the suite. + */ +function asPathBuildingTool(value: unknown): PathBuildingTool | null { + if (typeof value !== 'object' || value === null) return null + + const candidate = value as { id?: unknown; params?: unknown; request?: unknown } + if (typeof candidate.id !== 'string' || !candidate.id.startsWith('jira_')) return null + + const request = candidate.request as { url?: unknown } | undefined + if (typeof request?.url !== 'function') return null + + return { + id: candidate.id, + params: (candidate.params ?? {}) as Record, + buildUrl: request.url as UrlBuilder, + transformResponse: + typeof candidate.transformResponse === 'function' + ? (candidate.transformResponse as TransformResponse) + : null, + } +} + +/** + * Fills every declared parameter with a type-appropriate safe value, then + * overrides the single parameter under test. + */ +function buildParams(tool: PathBuildingTool, paramName: string, value: string): ToolParams { + const params: ToolParams = {} + for (const [name, definition] of Object.entries(tool.params)) { + if (definition.type === 'json' || definition.type === 'array') { + params[name] = [] + } else if (definition.type === 'number') { + params[name] = 1 + } else if (definition.type === 'boolean') { + params[name] = false + } else { + params[name] = SAFE_ID + } + } + Object.assign(params, FIXED_PARAMS) + params[paramName] = value + return params +} + +function buildUrl(tool: PathBuildingTool, paramName: string, value: string): URL { + return new URL(tool.buildUrl(buildParams(tool, paramName, value))) +} + +function segmentsOf(tool: PathBuildingTool, paramName: string, value: string): string[] { + return buildUrl(tool, paramName, value).pathname.split('/') +} + +/** Every (tool, parameter) pair whose value lands in a URL path segment. */ +const PATH_PARAMS = Object.values(jiraTools) + .map(asPathBuildingTool) + .filter((tool): tool is PathBuildingTool => tool !== null) + .flatMap((tool) => + Object.keys(tool.params) + .filter((name) => !(name in FIXED_PARAMS)) + .filter((name) => { + try { + return buildUrl(tool, name, PROBE_ID).pathname.includes(PROBE_ID) + } catch { + return false + } + }) + .map((name) => ({ label: `${tool.id} :: ${name}`, tool, paramName: name })) + ) + +describe('jira path-ID traversal safety', () => { + it('covers every Jira parameter that reaches a URL path segment', () => { + expect(PATH_PARAMS.length).toBeGreaterThanOrEqual(23) + }) + + describe.each(PATH_PARAMS)('$label', ({ tool, paramName }) => { + const baseline = segmentsOf(tool, paramName, PROBE_ID) + + it('stays under the resolved cloud instance prefix', () => { + expect(buildUrl(tool, paramName, PROBE_ID).pathname.startsWith(BASE_PATH)).toBe(true) + }) + + it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (value) => { + let url: URL + try { + url = buildUrl(tool, paramName, value) + } catch { + return + } + + expect(url.origin).toBe(BASE_ORIGIN) + expect(url.pathname.startsWith(BASE_PATH)).toBe(true) + + const actual = url.pathname.split('/') + expect(actual).toHaveLength(baseline.length) + baseline.forEach((segment, index) => { + if (segment.includes(PROBE_ID)) return + expect(actual[index]).toBe(segment) + }) + }) + + it.each(['..', '.'] as const)('rejects the bare %j segment by name', (value) => { + expect(() => buildUrl(tool, paramName, value)).toThrow(new RegExp(paramName)) + }) + + it.each(LEGITIMATE_IDS)('passes %j through unchanged', (value) => { + const actual = segmentsOf(tool, paramName, value) + + expect(actual).toHaveLength(baseline.length) + baseline.forEach((segment, index) => { + expect(actual[index]).toBe(segment.split(PROBE_ID).join(value)) + }) + }) + + it('trims surrounding whitespace without altering the id', () => { + expect(segmentsOf(tool, paramName, ` ${PROBE_ID} `)).toEqual(baseline) + }) + + it('does not let the id inject a query parameter', () => { + const url = buildUrl(tool, paramName, `${PROBE_ID}?injectedProbe=attacker`) + + expect(url.searchParams.get('injectedProbe')).toBeNull() + }) + }) +}) + +/** + * The second URL construction. + * + * A Jira tool that is invoked without a `cloudId` sends its configured request + * to the fixed `accessible-resources` discovery endpoint, then builds the real + * per-site URL inside `transformResponse` and issues it with a bare `fetch`. + * `PATH_PARAMS` above cannot see that URL — it only inspects what + * `request.url` returns — and `jira_bulk_read.projectId` reaches a path segment + * *only* through this second construction, so it would otherwise never be + * fuzzed at all. + * + * This block closes that gap by actually running `transformResponse` with the + * `cloudId` withheld, against a stubbed `fetch` that records the URLs the tool + * asks for. Discovery is driven the same way `PATH_PARAMS` is — probe every + * declared parameter, keep the ones that land in a path segment — so a new tool + * or a new ID parameter is covered here too, without registration. + */ +const DISCOVERY_PAYLOAD = [{ id: CLOUD_ID, url: `https://${FIXED_PARAMS.domain}`, name: 'example' }] + +function stubResponse(): Response { + return new Response(JSON.stringify(DISCOVERY_PAYLOAD), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function requestedUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + return input.url +} + +/** + * Runs a tool's `transformResponse` with `cloudId` withheld and returns the + * per-site URLs it tried to fetch. The discovery hop itself is filtered out, and + * a throw is swallowed: a guard rejecting the value is a pass, and the only + * thing this function reports is what reached the network. + */ +async function fallbackUrls( + tool: PathBuildingTool, + paramName: string, + value: string +): Promise { + if (!tool.transformResponse) return [] + + const requested: string[] = [] + const originalFetch = globalThis.fetch + globalThis.fetch = ((input: RequestInfo | URL) => { + requested.push(requestedUrl(input)) + return Promise.resolve(stubResponse()) + }) as typeof globalThis.fetch + + const params = buildParams(tool, paramName, value) + params.cloudId = undefined + + try { + await tool.transformResponse(stubResponse(), params) + } catch { + /* Only the URLs the tool asked for matter here. */ + } finally { + globalThis.fetch = originalFetch + } + + return requested.filter((url) => new URL(url).pathname.startsWith('/ex/jira/')) +} + +async function fallbackPaths( + tool: PathBuildingTool, + paramName: string, + value: string +): Promise { + const urls = await fallbackUrls(tool, paramName, value) + return urls.map((url) => new URL(url).pathname) +} + +/** + * Whether two resolved pathnames differ only where the probe sentinel sat. + * Segment *count* is part of the shape, which is what catches a popped prefix: + * a removed dot segment always shortens the path by one. + */ +function hasSameShape(baseline: string, actual: string): boolean { + const baselineSegments = baseline.split('/') + const actualSegments = actual.split('/') + if (baselineSegments.length !== actualSegments.length) return false + return baselineSegments.every( + (segment, index) => segment.includes(PROBE_ID) || segment === actualSegments[index] + ) +} + +const FALLBACK_CANDIDATES = Object.values(jiraTools) + .map(asPathBuildingTool) + .filter((tool): tool is PathBuildingTool => tool !== null) + .filter((tool) => tool.transformResponse !== null) + .flatMap((tool) => + Object.keys(tool.params) + .filter((name) => !(name in FIXED_PARAMS)) + .map((name) => ({ label: `${tool.id} :: ${name}`, tool, paramName: name })) + ) + +const FALLBACK_PATH_PARAMS: typeof FALLBACK_CANDIDATES = [] +for (const candidate of FALLBACK_CANDIDATES) { + const urls = await fallbackUrls(candidate.tool, candidate.paramName, PROBE_ID) + if (urls.some((url) => new URL(url).pathname.includes(PROBE_ID))) { + FALLBACK_PATH_PARAMS.push(candidate) + } +} + +describe('jira path-ID traversal safety in transformResponse-built URLs', () => { + it('covers every parameter that reaches a path segment of the second URL', () => { + expect(FALLBACK_PATH_PARAMS.length).toBeGreaterThanOrEqual(24) + }) + + it('reaches jira_bulk_read.projectId, which request.url cannot expose', () => { + expect(FALLBACK_PATH_PARAMS.map((entry) => entry.label)).toContain( + 'jira_bulk_read :: projectId' + ) + }) + + describe.each(FALLBACK_PATH_PARAMS)('$label', ({ tool, paramName }) => { + it.each(TRAVERSAL_IDS)('cannot reshape the second URL with %j', async (value) => { + const baseline = await fallbackPaths(tool, paramName, PROBE_ID) + const actual = await fallbackPaths(tool, paramName, value) + + for (const pathname of actual) { + expect(pathname.startsWith(BASE_PATH)).toBe(true) + expect(baseline.some((shape) => hasSameShape(shape, pathname))).toBe(true) + } + }) + + it.each(LEGITIMATE_IDS)('passes %j into the second URL unchanged', async (value) => { + const baseline = await fallbackPaths(tool, paramName, PROBE_ID) + const actual = await fallbackPaths(tool, paramName, value) + + for (const pathname of baseline.filter((entry) => entry.includes(PROBE_ID))) { + expect(actual).toContain(pathname.split(PROBE_ID).join(value)) + } + }) + }) +}) diff --git a/apps/sim/tools/jira/remove_watcher.ts b/apps/sim/tools/jira/remove_watcher.ts index 03088fef2f1..29bf3cd5d36 100644 --- a/apps/sim/tools/jira/remove_watcher.ts +++ b/apps/sim/tools/jira/remove_watcher.ts @@ -2,6 +2,7 @@ import type { JiraRemoveWatcherParams, JiraRemoveWatcherResponse } from '@/tools import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraRemoveWatcherTool: ToolConfig = { @@ -52,7 +53,7 @@ export const jiraRemoveWatcherTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/watchers?accountId=${encodeURIComponent(params.accountId?.trim() ?? '')}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/watchers?accountId=${encodeURIComponent(params.accountId?.trim() ?? '')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -68,7 +69,7 @@ export const jiraRemoveWatcherTool: ToolConfig { if (!params?.cloudId) { const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) - const watcherUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/watchers?accountId=${encodeURIComponent(params!.accountId?.trim() ?? '')}` + const watcherUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/watchers?accountId=${encodeURIComponent(params!.accountId?.trim() ?? '')}` const watcherResponse = await fetch(watcherUrl, { method: 'DELETE', headers: { diff --git a/apps/sim/tools/jira/retrieve.ts b/apps/sim/tools/jira/retrieve.ts index a1ad0c30c55..d909e7a0845 100644 --- a/apps/sim/tools/jira/retrieve.ts +++ b/apps/sim/tools/jira/retrieve.ts @@ -8,6 +8,7 @@ import { transformUser, } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' const logger = createLogger('JiraRetrieveTool') @@ -232,7 +233,7 @@ export const jiraRetrieveTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -251,7 +252,7 @@ export const jiraRetrieveTool: ToolConfig { - const issueUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations` + const issueUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations` const issueResponse = await fetch(issueUrl, { method: 'GET', headers: { @@ -273,7 +274,7 @@ export const jiraRetrieveTool: ToolConfig { - const base = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}` + const base = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}` const [commentsResp, worklogResp, watchersResp] = await Promise.all([ fetch(`${base}/comment?maxResults=100&orderBy=-created`, { headers: { Accept: 'application/json', Authorization: `Bearer ${params.accessToken}` }, diff --git a/apps/sim/tools/jira/transition_issue.ts b/apps/sim/tools/jira/transition_issue.ts index 4ddd33e32ab..398ed4a15c9 100644 --- a/apps/sim/tools/jira/transition_issue.ts +++ b/apps/sim/tools/jira/transition_issue.ts @@ -2,6 +2,7 @@ import type { JiraTransitionIssueParams, JiraTransitionIssueResponse } from '@/t import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { getJiraCloudId, toAdf } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const jiraTransitionIssueTool: ToolConfig< JiraTransitionIssueParams, @@ -67,7 +68,7 @@ export const jiraTransitionIssueTool: ToolConfig< request: { url: (params: JiraTransitionIssueParams) => { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/transitions` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/transitions` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -88,7 +89,7 @@ export const jiraTransitionIssueTool: ToolConfig< transformResponse: async (response: Response, params?: JiraTransitionIssueParams) => { const performTransition = async (cloudId: string) => { // First, fetch available transitions to get the name and target status - const transitionsUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/transitions` + const transitionsUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/transitions` const transitionsResp = await fetch(transitionsUrl, { method: 'GET', headers: { @@ -158,7 +159,7 @@ export const jiraTransitionIssueTool: ToolConfig< // Fetch transition metadata for the response try { - const transitionsUrl = `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/transitions` + const transitionsUrl = `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/transitions` const transitionsResp = await fetch(transitionsUrl, { method: 'GET', headers: { diff --git a/apps/sim/tools/jira/update_comment.ts b/apps/sim/tools/jira/update_comment.ts index 8e867a1999e..1cb43870fc2 100644 --- a/apps/sim/tools/jira/update_comment.ts +++ b/apps/sim/tools/jira/update_comment.ts @@ -2,6 +2,7 @@ import type { JiraUpdateCommentParams, JiraUpdateCommentResponse } from '@/tools import { SUCCESS_OUTPUT, TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types' import { extractAdfText, getJiraCloudId, toAdf, transformUser } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' /** * Transforms an update comment API response into typed output. @@ -81,7 +82,7 @@ export const jiraUpdateCommentTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/comment/${params.commentId?.trim() ?? ''}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/comment/${safeUrlPathSegment(params.commentId, 'commentId')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -106,7 +107,7 @@ export const jiraUpdateCommentTool: ToolConfig { - const commentUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/comment/${params!.commentId?.trim() ?? ''}` + const commentUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/comment/${safeUrlPathSegment(params!.commentId, 'commentId')}` const commentResponse = await fetch(commentUrl, { method: 'PUT', headers: { diff --git a/apps/sim/tools/jira/update_worklog.ts b/apps/sim/tools/jira/update_worklog.ts index 86a2c36630f..86f5d521951 100644 --- a/apps/sim/tools/jira/update_worklog.ts +++ b/apps/sim/tools/jira/update_worklog.ts @@ -8,6 +8,7 @@ import { transformUser, } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' function buildWorklogBody(params: JiraUpdateWorklogParams) { let timeSpentSeconds: number | undefined @@ -122,7 +123,7 @@ export const jiraUpdateWorklogTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey?.trim() ?? ''}/worklog/${params.worklogId?.trim() ?? ''}` + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${safeUrlPathSegment(params.issueKey, 'issueKey')}/worklog/${safeUrlPathSegment(params.worklogId, 'worklogId')}` } return 'https://api.atlassian.com/oauth/token/accessible-resources' }, @@ -143,7 +144,7 @@ export const jiraUpdateWorklogTool: ToolConfig { if (!params?.cloudId) { const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) - const worklogUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params!.issueKey?.trim() ?? ''}/worklog/${params!.worklogId?.trim() ?? ''}` + const worklogUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeUrlPathSegment(params!.issueKey, 'issueKey')}/worklog/${safeUrlPathSegment(params!.worklogId, 'worklogId')}` const worklogResponse = await fetch(worklogUrl, { method: 'PUT', headers: { diff --git a/apps/sim/tools/rootly/acknowledge_alert.ts b/apps/sim/tools/rootly/acknowledge_alert.ts index a403ad7299a..1ae893314eb 100644 --- a/apps/sim/tools/rootly/acknowledge_alert.ts +++ b/apps/sim/tools/rootly/acknowledge_alert.ts @@ -3,6 +3,7 @@ import type { RootlyAcknowledgeAlertResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyAcknowledgeAlertTool: ToolConfig< RootlyAcknowledgeAlertParams, @@ -29,7 +30,8 @@ export const rootlyAcknowledgeAlertTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/alerts/${params.alertId.trim()}/acknowledge`, + url: (params) => + `https://api.rootly.com/v1/alerts/${safeUrlPathSegment(params.alertId, 'alertId')}/acknowledge`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/add_incident_event.ts b/apps/sim/tools/rootly/add_incident_event.ts index cc9f1fe0ede..8d278ba15d7 100644 --- a/apps/sim/tools/rootly/add_incident_event.ts +++ b/apps/sim/tools/rootly/add_incident_event.ts @@ -3,6 +3,7 @@ import type { RootlyAddIncidentEventResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyAddIncidentEventTool: ToolConfig< RootlyAddIncidentEventParams, @@ -41,7 +42,8 @@ export const rootlyAddIncidentEventTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/events`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/events`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/add_subscribers.ts b/apps/sim/tools/rootly/add_subscribers.ts index 7035f013604..c3d92b2d4e8 100644 --- a/apps/sim/tools/rootly/add_subscribers.ts +++ b/apps/sim/tools/rootly/add_subscribers.ts @@ -1,5 +1,6 @@ import type { RootlyAddSubscribersParams, RootlyIncidentActionResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyAddSubscribersTool: ToolConfig< RootlyAddSubscribersParams, @@ -33,7 +34,7 @@ export const rootlyAddSubscribersTool: ToolConfig< request: { url: (params) => - `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/add_subscribers`, + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/add_subscribers`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/assign_incident_role.ts b/apps/sim/tools/rootly/assign_incident_role.ts index d016eaca5d8..488854000f8 100644 --- a/apps/sim/tools/rootly/assign_incident_role.ts +++ b/apps/sim/tools/rootly/assign_incident_role.ts @@ -3,6 +3,7 @@ import type { RootlyIncidentActionResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyAssignIncidentRoleTool: ToolConfig< RootlyAssignIncidentRoleParams, @@ -42,7 +43,7 @@ export const rootlyAssignIncidentRoleTool: ToolConfig< request: { url: (params) => - `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/assign_role_to_user`, + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/assign_role_to_user`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/create_action_item.ts b/apps/sim/tools/rootly/create_action_item.ts index 7297d19860f..2688230b4ba 100644 --- a/apps/sim/tools/rootly/create_action_item.ts +++ b/apps/sim/tools/rootly/create_action_item.ts @@ -3,6 +3,7 @@ import type { RootlyCreateActionItemResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyCreateActionItemTool: ToolConfig< RootlyCreateActionItemParams, @@ -71,7 +72,8 @@ export const rootlyCreateActionItemTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/action_items`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/action_items`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/create_status_page_event.ts b/apps/sim/tools/rootly/create_status_page_event.ts index d1506679024..3d186eba8a3 100644 --- a/apps/sim/tools/rootly/create_status_page_event.ts +++ b/apps/sim/tools/rootly/create_status_page_event.ts @@ -3,6 +3,7 @@ import type { RootlyCreateStatusPageEventResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyCreateStatusPageEventTool: ToolConfig< RootlyCreateStatusPageEventParams, @@ -61,7 +62,7 @@ export const rootlyCreateStatusPageEventTool: ToolConfig< request: { url: (params) => - `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/status-page-events`, + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/status-page-events`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/delete_action_item.ts b/apps/sim/tools/rootly/delete_action_item.ts index 055b2a4d594..c0f7e3d174b 100644 --- a/apps/sim/tools/rootly/delete_action_item.ts +++ b/apps/sim/tools/rootly/delete_action_item.ts @@ -3,6 +3,7 @@ import type { RootlyDeleteActionItemResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyDeleteActionItemTool: ToolConfig< RootlyDeleteActionItemParams, @@ -29,7 +30,8 @@ export const rootlyDeleteActionItemTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/action_items/${params.actionItemId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/action_items/${safeUrlPathSegment(params.actionItemId, 'actionItemId')}`, method: 'DELETE', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/delete_incident.ts b/apps/sim/tools/rootly/delete_incident.ts index 2b3a4cd0e80..32910ddac90 100644 --- a/apps/sim/tools/rootly/delete_incident.ts +++ b/apps/sim/tools/rootly/delete_incident.ts @@ -1,5 +1,6 @@ import type { RootlyDeleteIncidentParams, RootlyDeleteIncidentResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyDeleteIncidentTool: ToolConfig< RootlyDeleteIncidentParams, @@ -26,7 +27,8 @@ export const rootlyDeleteIncidentTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}`, method: 'DELETE', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/escalate_alert.ts b/apps/sim/tools/rootly/escalate_alert.ts index 33d806abb07..7b6c3242475 100644 --- a/apps/sim/tools/rootly/escalate_alert.ts +++ b/apps/sim/tools/rootly/escalate_alert.ts @@ -1,5 +1,6 @@ import type { RootlyAlertActionResponse, RootlyEscalateAlertParams } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyEscalateAlertTool: ToolConfig< RootlyEscalateAlertParams, @@ -38,7 +39,8 @@ export const rootlyEscalateAlertTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/alerts/${params.alertId.trim()}/escalate`, + url: (params) => + `https://api.rootly.com/v1/alerts/${safeUrlPathSegment(params.alertId, 'alertId')}/escalate`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/get_alert.ts b/apps/sim/tools/rootly/get_alert.ts index 3b148219fb1..940d02bc15b 100644 --- a/apps/sim/tools/rootly/get_alert.ts +++ b/apps/sim/tools/rootly/get_alert.ts @@ -1,5 +1,6 @@ import type { RootlyGetAlertParams, RootlyGetAlertResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyGetAlertTool: ToolConfig = { id: 'rootly_get_alert', @@ -23,7 +24,8 @@ export const rootlyGetAlertTool: ToolConfig `https://api.rootly.com/v1/alerts/${params.alertId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/alerts/${safeUrlPathSegment(params.alertId, 'alertId')}`, method: 'GET', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/get_incident.ts b/apps/sim/tools/rootly/get_incident.ts index 70b4f6c0f6d..9744630bc29 100644 --- a/apps/sim/tools/rootly/get_incident.ts +++ b/apps/sim/tools/rootly/get_incident.ts @@ -1,5 +1,6 @@ import type { RootlyGetIncidentParams, RootlyGetIncidentResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyGetIncidentTool: ToolConfig = { @@ -24,7 +25,8 @@ export const rootlyGetIncidentTool: ToolConfig `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}`, method: 'GET', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/list_action_items.ts b/apps/sim/tools/rootly/list_action_items.ts index 96eb804c97a..2c1d16c7264 100644 --- a/apps/sim/tools/rootly/list_action_items.ts +++ b/apps/sim/tools/rootly/list_action_items.ts @@ -3,6 +3,7 @@ import type { RootlyListActionItemsResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyListActionItemsTool: ToolConfig< RootlyListActionItemsParams, @@ -46,7 +47,7 @@ export const rootlyListActionItemsTool: ToolConfig< if (params.pageSize) queryParams.set('page[size]', String(params.pageSize)) if (params.pageNumber) queryParams.set('page[number]', String(params.pageNumber)) const qs = queryParams.toString() - return `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/action_items${qs ? `?${qs}` : ''}` + return `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/action_items${qs ? `?${qs}` : ''}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/rootly/list_incident_events.ts b/apps/sim/tools/rootly/list_incident_events.ts index 2e85a36b4cf..031f1b2e63e 100644 --- a/apps/sim/tools/rootly/list_incident_events.ts +++ b/apps/sim/tools/rootly/list_incident_events.ts @@ -3,6 +3,7 @@ import type { RootlyListIncidentEventsResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyListIncidentEventsTool: ToolConfig< RootlyListIncidentEventsParams, @@ -46,7 +47,7 @@ export const rootlyListIncidentEventsTool: ToolConfig< if (params.pageSize) queryParams.set('page[size]', String(params.pageSize)) if (params.pageNumber) queryParams.set('page[number]', String(params.pageNumber)) const qs = queryParams.toString() - return `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/events${qs ? `?${qs}` : ''}` + return `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/events${qs ? `?${qs}` : ''}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/rootly/mitigate_incident.ts b/apps/sim/tools/rootly/mitigate_incident.ts index 16aca28e5ef..8b57c4c284e 100644 --- a/apps/sim/tools/rootly/mitigate_incident.ts +++ b/apps/sim/tools/rootly/mitigate_incident.ts @@ -3,6 +3,7 @@ import type { RootlyMitigateIncidentParams, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyMitigateIncidentTool: ToolConfig< RootlyMitigateIncidentParams, @@ -35,7 +36,8 @@ export const rootlyMitigateIncidentTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/mitigate`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/mitigate`, method: 'PUT', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/path_safety.test.ts b/apps/sim/tools/rootly/path_safety.test.ts new file mode 100644 index 00000000000..0852a5e49af --- /dev/null +++ b/apps/sim/tools/rootly/path_safety.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + * + * Guards every Rootly tool against path traversal through an LLM-writable ID + * that gets interpolated into the request path. + * + * Incident, alert, action-item, and workflow IDs are `visibility: 'user-or-llm'`, + * so prompt injection controls them. Interpolating one raw let a value like + * `../../users/victim` escape its API prefix once `fetch` normalized the URL, + * re-aiming the request — and the workspace's Rootly bearer token — at an + * arbitrary Rootly resource, including on DELETE. + * `assertRequestUrlMatchesTrust` in `tools/request-transport.ts` only applies + * its canonicalization guard to internal `/api/` routes, so nothing downstream + * catches this. + * + * Wrapping the ID in `encodeURIComponent` is NOT enough, which is why the + * vector list below includes the bare `.` and `..` segments: both are made of + * unreserved characters, so they survive encoding untouched and the URL parser + * then removes them as dot segments, popping one path segment off a fixed host. + * Every assertion here resolves the built URL with `new URL(...)` — the same + * normalization `fetch` performs — rather than string-matching the template + * output, because string matching is exactly what let this through. + * + * The suite enumerates the tool barrel and then, per tool, every declared + * parameter that reaches a path segment, fuzzing **one parameter at a time** + * while the others hold a safe value. A new tool — or a new ID parameter on an + * existing tool — is therefore covered without anyone remembering to register + * it, and a still-guarded sibling parameter cannot mask an unguarded one by + * throwing first. + * + * Discovery inspects what `request.url` returns. That is total for this + * service: no Rootly tool issues a `fetch` of its own from `transformResponse`, + * so `request.url` is the only place a URL is built. Jira does build a second + * URL there, and its suite carries an extra block for it — if a Rootly tool + * ever grows one, this file needs the same. + */ +import { describe, expect, it } from 'vitest' +import * as rootlyTools from '@/tools/rootly/index' + +/** + * The bare `.` and `..` entries are the whole point: their omission is why an + * `encodeURIComponent`-only fix looks correct while the hole stays live. + */ +const TRAVERSAL_IDS = [ + '..', + '.', + ' .. ', + '../../users/victim', + '..%2f..%2fusers/victim', + '123/../../../users/victim', + '123?injectedProbe=attacker', + '123#fragment', + '123/events/../../../workflows', + '\\..\\..', +] as const + +/** Values a real user legitimately supplies; none may be rejected or altered. */ +const LEGITIMATE_IDS = [ + '4f1c2b3a-5d6e-4f70-8a91-b2c3d4e5f607', + '00000000-0000-4000-8000-000000000000', + '12345', + 'INC-42', + 'incident_abc123', + 'v1.2.3', + '..foo', + 'foo..', +] as const + +const SAFE_ID = 'SAFEID' + +/** Sentinel for the one parameter under test, so its slots are identifiable. */ +const PROBE_ID = 'PROBEID' + +const BASE_ORIGIN = 'https://api.rootly.com' + +/** Supplied by the platform, never by the model. */ +const FIXED_PARAMS: Record = { apiKey: 'token' } + +/** + * The slice of a tool this suite drives. Narrowing to it — rather than reaching + * for `ToolConfig` and an `as any` at the call site — keeps the + * harness typed end to end while staying agnostic about each tool's own param + * and response generics, which differ per tool and are irrelevant here. + */ +type ToolParams = Record + +type UrlBuilder = (params: ToolParams) => string + +interface ParamDefinition { + readonly type?: string +} + +interface PathBuildingTool { + readonly id: string + readonly params: Readonly> + readonly buildUrl: UrlBuilder +} + +/** + * Narrows a barrel export to a Rootly tool that builds its URL from params. + * Anything else — a type-only re-export, a tool with a static URL — yields + * `null` and drops out of the suite. + */ +function asPathBuildingTool(value: unknown): PathBuildingTool | null { + if (typeof value !== 'object' || value === null) return null + + const candidate = value as { id?: unknown; params?: unknown; request?: unknown } + if (typeof candidate.id !== 'string' || !candidate.id.startsWith('rootly_')) return null + + const request = candidate.request as { url?: unknown } | undefined + if (typeof request?.url !== 'function') return null + + return { + id: candidate.id, + params: (candidate.params ?? {}) as Record, + buildUrl: request.url as UrlBuilder, + } +} + +/** + * Fills every declared parameter with a type-appropriate safe value, then + * overrides the single parameter under test. + */ +function buildParams(tool: PathBuildingTool, paramName: string, value: string): ToolParams { + const params: ToolParams = {} + for (const [name, definition] of Object.entries(tool.params)) { + if (definition.type === 'json' || definition.type === 'array') { + params[name] = [] + } else if (definition.type === 'number') { + params[name] = 1 + } else if (definition.type === 'boolean') { + params[name] = false + } else { + params[name] = SAFE_ID + } + } + Object.assign(params, FIXED_PARAMS) + params[paramName] = value + return params +} + +function buildUrl(tool: PathBuildingTool, paramName: string, value: string): URL { + return new URL(tool.buildUrl(buildParams(tool, paramName, value))) +} + +function segmentsOf(tool: PathBuildingTool, paramName: string, value: string): string[] { + return buildUrl(tool, paramName, value).pathname.split('/') +} + +/** Every (tool, parameter) pair whose value lands in a URL path segment. */ +const PATH_PARAMS = Object.values(rootlyTools) + .map(asPathBuildingTool) + .filter((tool): tool is PathBuildingTool => tool !== null) + .flatMap((tool) => + Object.keys(tool.params) + .filter((name) => !(name in FIXED_PARAMS)) + .filter((name) => { + try { + return buildUrl(tool, name, PROBE_ID).pathname.includes(PROBE_ID) + } catch { + return false + } + }) + .map((name) => ({ label: `${tool.id} :: ${name}`, tool, paramName: name })) + ) + +describe('rootly path-ID traversal safety', () => { + it('covers every Rootly parameter that reaches a URL path segment', () => { + expect(PATH_PARAMS.length).toBeGreaterThanOrEqual(23) + }) + + describe.each(PATH_PARAMS)('$label', ({ tool, paramName }) => { + const baseline = segmentsOf(tool, paramName, PROBE_ID) + + it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (value) => { + let url: URL + try { + url = buildUrl(tool, paramName, value) + } catch { + return + } + + expect(url.origin).toBe(BASE_ORIGIN) + + const actual = url.pathname.split('/') + expect(actual).toHaveLength(baseline.length) + baseline.forEach((segment, index) => { + if (segment.includes(PROBE_ID)) return + expect(actual[index]).toBe(segment) + }) + }) + + it.each(['..', '.'] as const)('rejects the bare %j segment by name', (value) => { + expect(() => buildUrl(tool, paramName, value)).toThrow(new RegExp(paramName)) + }) + + it.each(LEGITIMATE_IDS)('passes %j through unchanged', (value) => { + const actual = segmentsOf(tool, paramName, value) + + expect(actual).toHaveLength(baseline.length) + baseline.forEach((segment, index) => { + expect(actual[index]).toBe(segment.split(PROBE_ID).join(value)) + }) + }) + + it('trims surrounding whitespace without altering the id', () => { + expect(segmentsOf(tool, paramName, ` ${PROBE_ID} `)).toEqual(baseline) + }) + + it('does not let the id inject a query parameter', () => { + const url = buildUrl(tool, paramName, `${PROBE_ID}?injectedProbe=attacker`) + + expect(url.searchParams.get('injectedProbe')).toBeNull() + }) + }) +}) diff --git a/apps/sim/tools/rootly/remove_subscribers.ts b/apps/sim/tools/rootly/remove_subscribers.ts index 86b9bdc73f9..98d3bcccc2d 100644 --- a/apps/sim/tools/rootly/remove_subscribers.ts +++ b/apps/sim/tools/rootly/remove_subscribers.ts @@ -3,6 +3,7 @@ import type { RootlyRemoveSubscribersParams, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyRemoveSubscribersTool: ToolConfig< RootlyRemoveSubscribersParams, @@ -36,7 +37,7 @@ export const rootlyRemoveSubscribersTool: ToolConfig< request: { url: (params) => - `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/remove_subscribers`, + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/remove_subscribers`, method: 'DELETE', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/resolve_alert.ts b/apps/sim/tools/rootly/resolve_alert.ts index 550dc61ceed..b4d4f160897 100644 --- a/apps/sim/tools/rootly/resolve_alert.ts +++ b/apps/sim/tools/rootly/resolve_alert.ts @@ -1,5 +1,6 @@ import type { RootlyResolveAlertParams, RootlyResolveAlertResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyResolveAlertTool: ToolConfig< RootlyResolveAlertParams, @@ -38,7 +39,8 @@ export const rootlyResolveAlertTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/alerts/${params.alertId.trim()}/resolve`, + url: (params) => + `https://api.rootly.com/v1/alerts/${safeUrlPathSegment(params.alertId, 'alertId')}/resolve`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/resolve_incident.ts b/apps/sim/tools/rootly/resolve_incident.ts index a6f4406ba3c..e85622a171f 100644 --- a/apps/sim/tools/rootly/resolve_incident.ts +++ b/apps/sim/tools/rootly/resolve_incident.ts @@ -3,6 +3,7 @@ import type { RootlyResolveIncidentParams, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyResolveIncidentTool: ToolConfig< RootlyResolveIncidentParams, @@ -35,7 +36,8 @@ export const rootlyResolveIncidentTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/resolve`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/resolve`, method: 'PUT', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/run_workflow.ts b/apps/sim/tools/rootly/run_workflow.ts index f37fbd8d73e..adff5841c34 100644 --- a/apps/sim/tools/rootly/run_workflow.ts +++ b/apps/sim/tools/rootly/run_workflow.ts @@ -1,5 +1,6 @@ import type { RootlyRunWorkflowParams, RootlyRunWorkflowResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyRunWorkflowTool: ToolConfig = { @@ -50,7 +51,7 @@ export const rootlyRunWorkflowTool: ToolConfig - `https://api.rootly.com/v1/workflows/${params.workflowId.trim()}/workflow_runs`, + `https://api.rootly.com/v1/workflows/${safeUrlPathSegment(params.workflowId, 'workflowId')}/workflow_runs`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/snooze_alert.ts b/apps/sim/tools/rootly/snooze_alert.ts index 339cd1440a6..4c83c4aaab9 100644 --- a/apps/sim/tools/rootly/snooze_alert.ts +++ b/apps/sim/tools/rootly/snooze_alert.ts @@ -1,5 +1,6 @@ import type { RootlyAlertActionResponse, RootlySnoozeAlertParams } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlySnoozeAlertTool: ToolConfig = { @@ -30,7 +31,8 @@ export const rootlySnoozeAlertTool: ToolConfig `https://api.rootly.com/v1/alerts/${params.alertId.trim()}/snooze`, + url: (params) => + `https://api.rootly.com/v1/alerts/${safeUrlPathSegment(params.alertId, 'alertId')}/snooze`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/unassign_incident_role.ts b/apps/sim/tools/rootly/unassign_incident_role.ts index cc977939807..3de05111fc8 100644 --- a/apps/sim/tools/rootly/unassign_incident_role.ts +++ b/apps/sim/tools/rootly/unassign_incident_role.ts @@ -3,6 +3,7 @@ import type { RootlyUnassignIncidentRoleParams, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyUnassignIncidentRoleTool: ToolConfig< RootlyUnassignIncidentRoleParams, @@ -42,7 +43,7 @@ export const rootlyUnassignIncidentRoleTool: ToolConfig< request: { url: (params) => - `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}/unassign_role_from_user`, + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}/unassign_role_from_user`, method: 'DELETE', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/update_action_item.ts b/apps/sim/tools/rootly/update_action_item.ts index df8a5e3cb57..bbdf82c907d 100644 --- a/apps/sim/tools/rootly/update_action_item.ts +++ b/apps/sim/tools/rootly/update_action_item.ts @@ -3,6 +3,7 @@ import type { RootlyUpdateActionItemResponse, } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyUpdateActionItemTool: ToolConfig< RootlyUpdateActionItemParams, @@ -71,7 +72,8 @@ export const rootlyUpdateActionItemTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/action_items/${params.actionItemId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/action_items/${safeUrlPathSegment(params.actionItemId, 'actionItemId')}`, method: 'PUT', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/update_alert.ts b/apps/sim/tools/rootly/update_alert.ts index ac64a2dffd2..b467c4ba1fa 100644 --- a/apps/sim/tools/rootly/update_alert.ts +++ b/apps/sim/tools/rootly/update_alert.ts @@ -1,5 +1,6 @@ import type { RootlyUpdateAlertParams, RootlyUpdateAlertResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyUpdateAlertTool: ToolConfig = { @@ -78,7 +79,8 @@ export const rootlyUpdateAlertTool: ToolConfig `https://api.rootly.com/v1/alerts/${params.alertId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/alerts/${safeUrlPathSegment(params.alertId, 'alertId')}`, method: 'PATCH', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json', diff --git a/apps/sim/tools/rootly/update_incident.ts b/apps/sim/tools/rootly/update_incident.ts index bebaf6f84e0..45377ade5c5 100644 --- a/apps/sim/tools/rootly/update_incident.ts +++ b/apps/sim/tools/rootly/update_incident.ts @@ -1,5 +1,6 @@ import type { RootlyUpdateIncidentParams, RootlyUpdateIncidentResponse } from '@/tools/rootly/types' import type { ToolConfig } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' export const rootlyUpdateIncidentTool: ToolConfig< RootlyUpdateIncidentParams, @@ -118,7 +119,8 @@ export const rootlyUpdateIncidentTool: ToolConfig< }, request: { - url: (params) => `https://api.rootly.com/v1/incidents/${params.incidentId.trim()}`, + url: (params) => + `https://api.rootly.com/v1/incidents/${safeUrlPathSegment(params.incidentId, 'incidentId')}`, method: 'PUT', headers: (params) => ({ 'Content-Type': 'application/vnd.api+json',