Skip to content

Commit 12ffff0

Browse files
committed
test(cloudflare,discord): fuzz path params one at a time, not as a whole object
The previous suites filled every string param with the same fuzz value and swallowed the throw: try { path = buildPath(tool, value) } catch { return } URL construction is eager, so the first guarded param to throw aborted the whole vector and every sibling param went untested. That inverted the property the suites were written to hold: once a tool had one guard, a *newly unguarded* sibling could no longer fail CI. It is the worst possible shape for these two services, where `channelId` + `messageId`, `serverId` + `roleId`, and `zoneId` + `rulesetId` + `ruleId` share one path. Both suites now enumerate (tool, param) pairs — discovered by probing one param at a time, so a new tool or a new path param appears with no edit here — and fuzz exactly one param while holding every sibling at a safe value. 133 pairs across 84 tools, up from 84 whole-tool cases. The vectors are also split by the outcome they must produce, replacing the tolerant try/catch: MUST_REJECT (dot segments and anything carrying a path separator) asserts a throw naming the offending param, and MUST_NEUTRALIZE (`?`/`#` inside a segment) asserts the segment shape is preserved. Nothing is skipped silently any more. Verified red-first against the tightened suite by reverting one guard on a multi-param tool — `messageId` on discord_delete_message and `ruleId` on cloudflare_delete_ruleset_rule — to an `encodeURIComponent`-only version, leaving their siblings guarded. That is exactly the case the old shape could not see: it produces 16 named failures now, while the old whole-object assertion passed 3/3 green on the identical code. No source change: the pair enumeration confirms every param that reaches a path is already guarded.
1 parent b0f53c6 commit 12ffff0

2 files changed

Lines changed: 168 additions & 114 deletions

File tree

apps/sim/tools/cloudflare/path_safety.test.ts

Lines changed: 85 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -19,33 +19,49 @@
1919
* performs — rather than string-matching the template output, because string
2020
* matching is exactly what let this through.
2121
*
22-
* The tool list is enumerated from the barrel, so a newly added Cloudflare tool
23-
* that interpolates an unguarded ID fails here without anyone editing this file.
22+
* The suite enumerates **(tool, param) pairs** and fuzzes one param at a time,
23+
* holding every sibling at a safe value. Fuzzing all params at once cannot work
24+
* here: the first guard to throw aborts URL construction, so a tool's remaining
25+
* params stop being exercised the moment one of them is fixed. Pair enumeration
26+
* is what makes "a newly unguarded param fails CI" actually true for a tool that
27+
* already has a guarded param — the dominant shape in this service, where
28+
* `accountId` + `appId` + `policyId` and `zoneId` + `rulesetId` + `ruleId`
29+
* share one path.
2430
*/
2531
import { describe, expect, it } from 'vitest'
2632
import * as cloudflareTools from '@/tools/cloudflare'
2733
import type { ToolConfig } from '@/tools/types'
2834

2935
const API_ORIGIN = 'https://api.cloudflare.com'
3036
const API_PREFIX = '/client/v4/'
37+
const CREDENTIAL_PARAM = 'apiKey'
3138

3239
/**
40+
* Vectors the guard must **reject outright**. Each is either a bare dot segment
41+
* or carries a path separator, so encoding it would leave a live traversal.
3342
* The bare `.` and `..` entries are the whole point: their omission is why an
3443
* `encodeURIComponent`-only fix looks correct while the hole stays live.
3544
*/
36-
const TRAVERSAL_IDS = [
45+
const MUST_REJECT = [
3746
'..',
3847
'.',
3948
' .. ',
4049
'../../accounts/victim-account',
4150
'..%2f..%2faccounts/victim-account',
4251
'023e105f4ecef8ad9ca31a8372d0c353/../../accounts/victim-account',
43-
'023e105f4ecef8ad9ca31a8372d0c353?account_id=attacker',
44-
'023e105f4ecef8ad9ca31a8372d0c353#fragment',
4552
'023e105f4ecef8ad9ca31a8372d0c353/dns_records/../../../zones',
4653
'\\..\\..',
4754
] as const
4855

56+
/**
57+
* Vectors that are not traversals but must not be able to reshape the request:
58+
* a `?` or `#` inside a segment has to stay inside that segment.
59+
*/
60+
const MUST_NEUTRALIZE = [
61+
'023e105f4ecef8ad9ca31a8372d0c353?account_id=attacker',
62+
'023e105f4ecef8ad9ca31a8372d0c353#frag',
63+
] as const
64+
4965
/** Values a real user legitimately supplies; none may be rejected or altered. */
5066
const LEGITIMATE_IDS = [
5167
'023e105f4ecef8ad9ca31a8372d0c353',
@@ -61,6 +77,8 @@ const LEGITIMATE_IDS = [
6177
] as const
6278

6379
const SAFE_ID = 'SAFEID'
80+
const PROBE = 'PROBEVALUE'
81+
const TRIM_SAMPLE = '023e105f4ecef8ad9ca31a8372d0c353'
6482

6583
type AnyTool = ToolConfig<any, any>
6684

@@ -74,112 +92,118 @@ function isCloudflareTool(value: unknown): value is AnyTool {
7492
}
7593

7694
/**
77-
* Builds a param object for a tool, filling every declared string param with
78-
* `value` so whichever one reaches the path is exercised.
95+
* Builds a param object with every string param at a known-safe value, then
96+
* applies one override so exactly one param carries the value under test.
7997
*/
80-
function buildParams(tool: AnyTool, value: string): Record<string, unknown> {
81-
const params: Record<string, unknown> = { apiKey: 'cf-token' }
98+
function buildParams(
99+
tool: AnyTool,
100+
overrides: Record<string, unknown> = {}
101+
): Record<string, unknown> {
102+
const params: Record<string, unknown> = { [CREDENTIAL_PARAM]: 'cf-token' }
82103
for (const [name, def] of Object.entries(tool.params ?? {})) {
83-
if (name === 'apiKey') continue
104+
if (name === CREDENTIAL_PARAM) continue
84105
const type = (def as { type?: string }).type
85-
if (type === 'json' || type === 'array') {
106+
if (type === 'json' || type === 'array' || type === 'file[]') {
86107
params[name] = []
87108
} else if (type === 'number') {
88109
params[name] = 1
89110
} else if (type === 'boolean') {
90111
params[name] = false
91112
} else {
92-
params[name] = value
113+
params[name] = SAFE_ID
93114
}
94115
}
95-
return params
116+
return { ...params, ...overrides }
96117
}
97118

98-
function buildUrl(tool: AnyTool, value: string): URL {
119+
function buildUrl(tool: AnyTool, overrides: Record<string, unknown> = {}): URL {
99120
const url = tool.request?.url
100121
if (typeof url !== 'function') {
101122
throw new Error(`${tool.id} does not build its URL from params`)
102123
}
103-
return new URL(url(buildParams(tool, value) as any))
124+
return new URL(url(buildParams(tool, overrides) as any))
104125
}
105126

106127
function segmentsOf(pathname: string): string[] {
107128
return pathname.split('/')
108129
}
109130

110-
const DYNAMIC_PATH_TOOLS = Object.values(cloudflareTools)
131+
const TOOLS = Object.values(cloudflareTools)
111132
.filter(isCloudflareTool)
112133
.filter((tool) => typeof tool.request?.url === 'function')
113-
.filter((tool) => {
114-
try {
115-
return buildUrl(tool, SAFE_ID).pathname.includes(SAFE_ID)
116-
} catch {
117-
return false
118-
}
134+
135+
/**
136+
* Every (tool, param) pair where that param alone reaches the request path,
137+
* discovered by probing one param at a time. A newly added tool — or a newly
138+
* added path param on an existing tool — appears here with no edit to this file.
139+
*/
140+
const PATH_PARAM_PAIRS = TOOLS.flatMap((tool) =>
141+
Object.keys(tool.params ?? {})
142+
.filter((param) => param !== CREDENTIAL_PARAM)
143+
.filter((param) => {
144+
try {
145+
return buildUrl(tool, { [param]: PROBE }).pathname.includes(PROBE)
146+
} catch {
147+
return false
148+
}
149+
})
150+
.map((param) => ({ name: `${tool.id} / ${param}`, tool, param }))
151+
)
152+
153+
describe('cloudflare path-param traversal safety', () => {
154+
it('finds every (tool, param) pair that reaches the request path', () => {
155+
expect(PATH_PARAM_PAIRS.length).toBeGreaterThanOrEqual(65)
119156
})
120-
.map((tool) => ({ name: tool.id, tool }))
121157

122-
describe('cloudflare path-ID traversal safety', () => {
123-
it('covers every Cloudflare tool that interpolates an ID into its path', () => {
124-
expect(DYNAMIC_PATH_TOOLS.length).toBeGreaterThanOrEqual(40)
158+
it('covers multi-param paths, where whole-object fuzzing goes blind', () => {
159+
const counts = new Map<string, number>()
160+
for (const { tool } of PATH_PARAM_PAIRS) {
161+
counts.set(tool.id, (counts.get(tool.id) ?? 0) + 1)
162+
}
163+
const multiParamTools = [...counts.values()].filter((count) => count > 1)
164+
165+
expect(multiParamTools.length).toBeGreaterThanOrEqual(15)
125166
})
126167

127-
describe.each(DYNAMIC_PATH_TOOLS)('$name', ({ tool }) => {
128-
const baseline = segmentsOf(buildUrl(tool, SAFE_ID).pathname)
168+
describe.each(PATH_PARAM_PAIRS)('$name', ({ tool, param }) => {
169+
const baseline = segmentsOf(buildUrl(tool, { [param]: PROBE }).pathname)
129170

130-
it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (value) => {
131-
let url: URL
132-
try {
133-
url = buildUrl(tool, value)
134-
} catch {
135-
return
136-
}
171+
it.each(MUST_REJECT)('rejects %j outright', (value) => {
172+
expect(() => buildUrl(tool, { [param]: value })).toThrow(
173+
new RegExp(`${param}|path traversal|path separator`)
174+
)
175+
})
176+
177+
it.each(MUST_NEUTRALIZE)('confines %j to a single segment', (value) => {
178+
const url = buildUrl(tool, { [param]: value })
137179

138180
expect(url.origin).toBe(API_ORIGIN)
139181
expect(url.pathname.startsWith(API_PREFIX)).toBe(true)
182+
expect(url.searchParams.get('account_id')).toBeNull()
183+
expect(url.hash).toBe('')
140184

141185
const actual = segmentsOf(url.pathname)
142186
expect(actual).toHaveLength(baseline.length)
143187
baseline.forEach((segment, index) => {
144-
if (segment === SAFE_ID) return
188+
if (segment.includes(PROBE)) return
145189
expect(actual[index]).toBe(segment)
146190
})
147191
})
148192

149-
it.each(TRAVERSAL_IDS)('never smuggles a query parameter via %j', (value) => {
150-
let url: URL
151-
try {
152-
url = buildUrl(tool, value)
153-
} catch {
154-
return
155-
}
156-
157-
expect(url.searchParams.get('account_id')).toBeNull()
158-
})
159-
160-
it('rejects a bare dot-dot segment instead of silently popping the prefix', () => {
161-
expect(() => buildUrl(tool, '..')).toThrow(/path traversal is not allowed/)
162-
})
163-
164-
it('rejects a bare dot segment', () => {
165-
expect(() => buildUrl(tool, '.')).toThrow(/path traversal is not allowed/)
166-
})
167-
168193
it.each(LEGITIMATE_IDS)('passes %j through unchanged', (value) => {
169-
const actual = segmentsOf(buildUrl(tool, value).pathname)
194+
const actual = segmentsOf(buildUrl(tool, { [param]: value }).pathname)
170195

171196
expect(actual).toHaveLength(baseline.length)
172197
baseline.forEach((segment, index) => {
173-
expect(actual[index]).toBe(segment === SAFE_ID ? value : segment)
198+
expect(actual[index]).toBe(segment.replaceAll(PROBE, value))
174199
})
175200
})
176201

177-
it('trims surrounding whitespace off a legitimate ID', () => {
178-
const actual = segmentsOf(buildUrl(tool, ' 023e105f4ecef8ad9ca31a8372d0c353 ').pathname)
202+
it('trims surrounding whitespace off a legitimate value', () => {
203+
const actual = segmentsOf(buildUrl(tool, { [param]: ` ${TRIM_SAMPLE} ` }).pathname)
179204

180205
baseline.forEach((segment, index) => {
181-
if (segment !== SAFE_ID) return
182-
expect(actual[index]).toBe('023e105f4ecef8ad9ca31a8372d0c353')
206+
expect(actual[index]).toBe(segment.replaceAll(PROBE, TRIM_SAMPLE))
183207
})
184208
})
185209
})

0 commit comments

Comments
 (0)