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 */
2531import { describe , expect , it } from 'vitest'
2632import * as cloudflareTools from '@/tools/cloudflare'
2733import type { ToolConfig } from '@/tools/types'
2834
2935const API_ORIGIN = 'https://api.cloudflare.com'
3036const 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. */
5066const LEGITIMATE_IDS = [
5167 '023e105f4ecef8ad9ca31a8372d0c353' ,
@@ -61,6 +77,8 @@ const LEGITIMATE_IDS = [
6177] as const
6278
6379const SAFE_ID = 'SAFEID'
80+ const PROBE = 'PROBEVALUE'
81+ const TRIM_SAMPLE = '023e105f4ecef8ad9ca31a8372d0c353'
6482
6583type 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
106127function 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 ( / p a t h t r a v e r s a l i s n o t a l l o w e d / )
162- } )
163-
164- it ( 'rejects a bare dot segment' , ( ) => {
165- expect ( ( ) => buildUrl ( tool , '.' ) ) . toThrow ( / p a t h t r a v e r s a l i s n o t a l l o w e d / )
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