55 * path against path traversal.
66 *
77 * The index name and object ID are `visibility: 'user-or-llm'`, so prompt
8- * injection controls them. A value like `..` pops a path segment once `fetch`
9- * normalizes the URL, re-aiming the request and the caller's admin API key at
10- * a sibling endpoint — `DELETE /1/indexes/<index>/<objectID>` becomes
11- * `DELETE /1/indexes/<index>`, deleting the whole index instead of one record.
8+ * injection controls them. A value of exactly `.` pops nothing but the record:
9+ * `DELETE /1/indexes/myindex/.` normalizes to `DELETE /1/indexes/myindex`,
10+ * deleting the whole index instead of one record. A value of exactly `..` pops
11+ * one segment further — `/1/indexes/myindex/..` becomes `/1/indexes/` — re-aiming
12+ * the request and the caller's admin API key at the list-indices route. Both were
13+ * verified through `new URL(...)`; neither is the "delete the index" escalation
14+ * the other one is, so both are rejected.
1215 *
1316 * `applicationId` is deliberately NOT asserted on here: it is interpolated
1417 * into the HOST (`https://<appId>-dsn.algolia.net`), not the path, and the
2124 * resolves the built URL through `new URL(...)` — the same normalization
2225 * `fetch` performs — and compares *segment shape* rather than template text,
2326 * because `pathname.startsWith(prefix)` stays green after a segment is popped.
27+ *
28+ * The vector lists are **per parameter**, because the two path parameters do
29+ * not share a charset. Algolia validates `indexName` (`/1/indexes/instant%2Fsearch/settings`
30+ * → `400 indexName is not valid`) but not `objectID`: `foo%2Fbar` and a
31+ * URL-keyed id both return `404 ObjectID does not exist`, i.e. well-formed but
32+ * absent, and Algolia's own conformance suite exercises `Batman and Robin`.
33+ * A single shared list that contained no `/`-bearing legitimate value is what
34+ * let a guard rejecting `/` in `objectID` ship green.
2435 */
2536import { describe , expect , it } from 'vitest'
2637import * as toolModule from '@/tools/algolia/index'
2738import type { ToolConfig } from '@/tools/types'
2839
2940type AnyTool = ToolConfig < any , any >
3041
31- /** Vectors the guard must reject outright; no encoding neutralizes them. */
32- const REJECTED = [ '..' , '.' , ' .. ' , 'a/../../b' , '\\..\\..' ] as const
42+ /**
43+ * Parameters whose value is an opaque record id rather than a named resource.
44+ * Algolia treats `objectID` as an arbitrary string — a `/` inside it is a
45+ * legal, common (URL-keyed) id, not a separator — so it is guarded by
46+ * collapsing the whole value into one percent-encoded segment instead of
47+ * rejecting separators.
48+ */
49+ const OPAQUE_ID_PARAMS = new Set ( [ 'objectID' ] )
50+
51+ /** No encoding scheme neutralizes these; every guard must reject them. */
52+ const DOT_SEGMENT_VECTORS = [ '..' , '.' , ' .. ' ] as const
53+
54+ /**
55+ * Separator-bearing vectors. These are rejected only by parameters that
56+ * address a *named* resource, where a separator means the caller passed
57+ * something other than what the parameter addresses. For an opaque id they are
58+ * legitimate values and appear in {@link OPAQUE_LEGITIMATE} instead.
59+ */
60+ const SEPARATOR_VECTORS = [ 'a/../../b' , '\\..\\..' ] as const
3361
3462/**
3563 * Vectors `encodeURIComponent` genuinely does neutralize — `%` and `?` are
@@ -49,6 +77,32 @@ const LEGITIMATE = [
4977 'foo..' ,
5078] as const
5179
80+ /**
81+ * Legitimate ids that only an opaque-id parameter accepts. The URL-keyed form
82+ * is the common site-search pattern; `Batman and Robin` is lifted from
83+ * Algolia's own client conformance suite (`/1/indexes/cts_e2e_browse/Batman%20and%20Robin`
84+ * → 200); `a/../../b` and `docs/` are legal ids whose dot and trailing
85+ * separators must survive as inert `%2F`-joined text rather than be rejected
86+ * or emitted as real separators.
87+ */
88+ const OPAQUE_LEGITIMATE = [
89+ 'https://example.com/docs/getting-started' ,
90+ 'Batman and Robin' ,
91+ 'foo/bar' ,
92+ 'a/../../b' ,
93+ 'docs/' ,
94+ ] as const
95+
96+ function rejectedFor ( param : string ) : readonly string [ ] {
97+ return OPAQUE_ID_PARAMS . has ( param )
98+ ? DOT_SEGMENT_VECTORS
99+ : [ ...DOT_SEGMENT_VECTORS , ...SEPARATOR_VECTORS ]
100+ }
101+
102+ function legitimateFor ( param : string ) : readonly string [ ] {
103+ return OPAQUE_ID_PARAMS . has ( param ) ? [ ...LEGITIMATE , ...OPAQUE_LEGITIMATE ] : LEGITIMATE
104+ }
105+
52106const ID_PREFIX = 'SAFE'
53107const TOOL_ID_PREFIX = 'algolia_'
54108
@@ -66,9 +120,9 @@ function isTool(value: unknown): value is AnyTool {
66120}
67121
68122/**
69- * Number-typed parameters are stringified into the path by the tool
70- * (`algolia_get_task_status` does `String(params.taskID)`) , so they need a
71- * numeric marker to be discoverable at all. Skipping them would silently drop
123+ * Number-typed parameters (`algolia_get_task_status`'s `taskID`) reach the
124+ * guard as a JSON number and are stringified by `toGuardedString` , so they need
125+ * a numeric marker to be discoverable at all. Skipping them would silently drop
72126 * a real guard site from coverage.
73127 */
74128const NUMBER_MARKERS = new Map < string , string > ( )
@@ -153,7 +207,7 @@ describe('Algolia path-parameter traversal safety', () => {
153207 expect ( slot ) . toBeGreaterThan ( 0 )
154208 } )
155209
156- it . each ( REJECTED ) ( 'rejects %j instead of reshaping the path' , ( value ) => {
210+ it . each ( rejectedFor ( param ) ) ( 'rejects %j instead of reshaping the path' , ( value ) => {
157211 expect ( ( ) => buildUrl ( tool , param , value ) ) . toThrow ( new RegExp ( param ) )
158212 } )
159213
@@ -170,7 +224,7 @@ describe('Algolia path-parameter traversal safety', () => {
170224 expect ( url . searchParams . get ( 'foo' ) ) . toBeNull ( )
171225 } )
172226
173- it . each ( LEGITIMATE ) ( 'passes %j through byte-identical' , ( value ) => {
227+ it . each ( legitimateFor ( param ) ) ( 'passes %j through byte-identical' , ( value ) => {
174228 const url = buildUrl ( tool , param , value )
175229 const segments = url . pathname . split ( '/' )
176230
@@ -181,6 +235,12 @@ describe('Algolia path-parameter traversal safety', () => {
181235 index === slot ? value : segment
182236 )
183237 } )
238+
239+ expect ( segments [ slot ] ) . toBe ( encodeURIComponent ( value ) )
240+
241+ if ( encodeURIComponent ( value ) === value ) {
242+ expect ( segments [ slot ] ) . toBe ( value )
243+ }
184244 } )
185245 } )
186246 } )
@@ -211,3 +271,55 @@ describe('Algolia guards every path param independently', () => {
211271 } )
212272 } )
213273} )
274+
275+ /**
276+ * Pins the two dot-segment normalizations the guards exist to prevent, through
277+ * the same `new URL(...)` the `fetch` implementation applies, and shows that the
278+ * relaxation for opaque ids did not re-open either one.
279+ *
280+ * The escalation is asymmetric, which the previous docstring here had backwards:
281+ * `.` pops only the record segment and leaves the *index* addressed — that is
282+ * the "delete the whole index" vector on `DELETE /1/indexes/<index>/<objectID>`.
283+ * `..` pops one further and lands on `/1/indexes/`, re-aiming the admin key at
284+ * the list/create-index route. Both are rejected; neither is merely encoded.
285+ */
286+ describe ( 'Algolia dot-segment normalization' , ( ) => {
287+ const RECORD_ROUTE = 'https://app.algolia.net/1/indexes/myindex/'
288+
289+ it ( 'confirms "." collapses the record route onto the index itself' , ( ) => {
290+ expect ( new URL ( `${ RECORD_ROUTE } .` ) . pathname ) . toBe ( '/1/indexes/myindex/' )
291+ } )
292+
293+ it ( 'confirms ".." pops the index segment too' , ( ) => {
294+ expect ( new URL ( `${ RECORD_ROUTE } ..` ) . pathname ) . toBe ( '/1/indexes/' )
295+ expect ( encodeURIComponent ( '..' ) ) . toBe ( '..' )
296+ } )
297+
298+ it ( 'still rejects an exact dot segment in an opaque objectID' , ( ) => {
299+ for ( const { tool, pathParams } of PATH_TOOLS ) {
300+ for ( const param of pathParams . filter ( ( name ) => OPAQUE_ID_PARAMS . has ( name ) ) ) {
301+ expect ( ( ) => buildUrl ( tool , param , '.' ) ) . toThrow ( new RegExp ( param ) )
302+ expect ( ( ) => buildUrl ( tool , param , '..' ) ) . toThrow ( new RegExp ( param ) )
303+ }
304+ }
305+ } )
306+
307+ it ( 'collapses a slash-bearing opaque objectID into one %2F-joined segment' , ( ) => {
308+ const opaque = PATH_TOOLS . flatMap ( ( { tool, pathParams } ) =>
309+ pathParams . filter ( ( name ) => OPAQUE_ID_PARAMS . has ( name ) ) . map ( ( name ) => ( { tool, name } ) )
310+ )
311+
312+ expect ( opaque . length ) . toBeGreaterThan ( 0 )
313+
314+ for ( const { tool, name } of opaque ) {
315+ const baselineSegments = buildUrl ( tool ) . pathname . split ( '/' )
316+ const url = buildUrl ( tool , name , 'a/../../b' )
317+ const segments = url . pathname . split ( '/' )
318+ const slot = baselineSegments . indexOf ( markerFor ( name , ( tool . params as any ) [ name ] ?. type ) )
319+
320+ expect ( segments ) . toHaveLength ( baselineSegments . length )
321+ expect ( segments [ slot ] ) . toBe ( 'a%2F..%2F..%2Fb' )
322+ expect ( decodeURIComponent ( segments [ slot ] ) ) . toBe ( 'a/../../b' )
323+ }
324+ } )
325+ } )
0 commit comments