Skip to content

Commit ff26be9

Browse files
committed
test(tools): add path-safety suites for the last four services
algolia, box, x and spotify received path guards but had no test files at all, so a future edit dropping a guard would go unnoticed. All 20 guarded services now carry the same suite shape: tools enumerated from the barrel, params classified by where a sentinel lands in the resolved URL so query- and host-zone values drop out structurally, dot segments asserted to throw, encoded vectors asserted inert, and one param poisoned at a time so an unguarded second param cannot hide behind the first guard's throw. Two service specifics the probe had to handle: algolia's taskID is declared type:'number', so a string-only sentinel skips it entirely; and X builds its second path param conditionally on an action value, so the suite drives those branches and asserts the reached param list matches the declared one. Also fixes a soft spot in the discord suite: a tool whose URL builder threw unconditionally was silently skipped rather than reported, so a tool that became unbuildable looked covered. Those are now recorded and asserted empty.
1 parent 7661b02 commit ff26be9

5 files changed

Lines changed: 839 additions & 4 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Guards every Algolia tool that interpolates a parameter into its request
5+
* path against path traversal.
6+
*
7+
* 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.
12+
*
13+
* `applicationId` is deliberately NOT asserted on here: it is interpolated
14+
* into the HOST (`https://<appId>-dsn.algolia.net`), not the path, and the
15+
* classification below resolves through `new URL(...).pathname` so host-zone
16+
* and query-zone parameters drop out structurally.
17+
*
18+
* `encodeURIComponent` is NOT a fix on its own: `.` and `..` are unreserved,
19+
* so they survive encoding untouched and the WHATWG URL parser then removes
20+
* them as dot segments. Only value rejection works. Every assertion below
21+
* resolves the built URL through `new URL(...)` — the same normalization
22+
* `fetch` performs — and compares *segment shape* rather than template text,
23+
* because `pathname.startsWith(prefix)` stays green after a segment is popped.
24+
*/
25+
import { describe, expect, it } from 'vitest'
26+
import * as toolModule from '@/tools/algolia/index'
27+
import type { ToolConfig } from '@/tools/types'
28+
29+
type AnyTool = ToolConfig<any, any>
30+
31+
/** Vectors the guard must reject outright; no encoding neutralizes them. */
32+
const REJECTED = ['..', '.', ' .. ', 'a/../../b', '\\..\\..'] as const
33+
34+
/**
35+
* Vectors `encodeURIComponent` genuinely does neutralize — `%` and `?` are
36+
* escaped, so the value stays one inert segment. These must NOT throw, and
37+
* they are the vectors that reach a *second* path parameter: a rejected value
38+
* throws at the first guard, masking an unguarded one further along.
39+
*/
40+
const NEUTRALIZED = ['%2e%2e', '..%2f..', 'x?foo=attacker'] as const
41+
42+
/** Values a real caller supplies; every one must survive byte-identical. */
43+
const LEGITIMATE = [
44+
'products',
45+
'my-index.v2',
46+
'prod-catalog_2024',
47+
'obj.123-abc',
48+
'..foo',
49+
'foo..',
50+
] as const
51+
52+
const ID_PREFIX = 'SAFE'
53+
const TOOL_ID_PREFIX = 'algolia_'
54+
55+
/** No Algolia path template branches on a parameter value. */
56+
const BRANCH_OVERRIDES: Record<string, Record<string, unknown>> = {}
57+
58+
function isTool(value: unknown): value is AnyTool {
59+
return (
60+
typeof value === 'object' &&
61+
value !== null &&
62+
typeof (value as AnyTool).id === 'string' &&
63+
(value as AnyTool).id.startsWith(TOOL_ID_PREFIX) &&
64+
typeof (value as AnyTool).request?.url === 'function'
65+
)
66+
}
67+
68+
/**
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
72+
* a real guard site from coverage.
73+
*/
74+
const NUMBER_MARKERS = new Map<string, string>()
75+
76+
function markerFor(name: string, type?: string): string {
77+
if (type === 'number') {
78+
const existing = NUMBER_MARKERS.get(name)
79+
if (existing) return existing
80+
const marker = String(9_000_001 + NUMBER_MARKERS.size)
81+
NUMBER_MARKERS.set(name, marker)
82+
return marker
83+
}
84+
return `${ID_PREFIX}${name}`
85+
}
86+
87+
/**
88+
* Fills every declared parameter, giving each one a distinct marker so the
89+
* segment it occupies can be located, applies any per-tool overrides needed to
90+
* reach a conditional branch, then overrides exactly one parameter with
91+
* `value` when `poison` names it.
92+
*/
93+
function buildParams(tool: AnyTool, poison?: string, value?: string): Record<string, unknown> {
94+
const params: Record<string, unknown> = {}
95+
for (const [name, def] of Object.entries<any>(tool.params ?? {})) {
96+
const type = def.type
97+
if (type === 'json' || type === 'object') params[name] = {}
98+
else if (type === 'array') params[name] = []
99+
else if (type === 'number') params[name] = Number(markerFor(name, type))
100+
else if (type === 'boolean') params[name] = false
101+
else params[name] = markerFor(name, type)
102+
}
103+
Object.assign(params, BRANCH_OVERRIDES[tool.id] ?? {})
104+
if (poison !== undefined) params[poison] = value
105+
return params
106+
}
107+
108+
function buildUrl(tool: AnyTool, poison?: string, value?: string): URL {
109+
return new URL((tool.request?.url as (p: any) => string)(buildParams(tool, poison, value)))
110+
}
111+
112+
/**
113+
* The parameters this tool interpolates into the PATH. Classification goes
114+
* through `new URL(...).pathname`, so query-zone and host-zone parameters are
115+
* excluded structurally rather than by name.
116+
*/
117+
function pathParamsOf(tool: AnyTool): string[] {
118+
const pathname = buildUrl(tool).pathname
119+
return Object.keys(tool.params ?? {}).filter((name) => {
120+
const def = (tool.params as any)[name]
121+
return pathname.includes(markerFor(name, def?.type))
122+
})
123+
}
124+
125+
const PATH_TOOLS = Object.values(toolModule)
126+
.filter(isTool)
127+
.map((tool) => ({ name: tool.id, tool, pathParams: pathParamsOf(tool) }))
128+
.filter((entry) => entry.pathParams.length > 0)
129+
130+
const TOTAL_PATH_PARAMS = PATH_TOOLS.reduce((sum, entry) => sum + entry.pathParams.length, 0)
131+
132+
describe('Algolia path-parameter traversal safety', () => {
133+
it('covers every tool that interpolates a parameter into its path', () => {
134+
expect(PATH_TOOLS.length).toBe(13)
135+
expect(TOTAL_PATH_PARAMS).toBe(18)
136+
})
137+
138+
it('classifies the host-zone applicationId out of the path', () => {
139+
for (const { tool, pathParams } of PATH_TOOLS) {
140+
expect(pathParams).not.toContain('applicationId')
141+
expect(buildUrl(tool).hostname).toContain('algolia.net')
142+
}
143+
})
144+
145+
describe.each(PATH_TOOLS)('$name', ({ tool, pathParams }) => {
146+
const baseline = buildUrl(tool)
147+
const baselineSegments = baseline.pathname.split('/')
148+
149+
describe.each(pathParams)('%s', (param) => {
150+
const slot = baselineSegments.indexOf(markerFor(param, (tool.params as any)[param]?.type))
151+
152+
it('occupies exactly one path segment in the baseline', () => {
153+
expect(slot).toBeGreaterThan(0)
154+
})
155+
156+
it.each(REJECTED)('rejects %j instead of reshaping the path', (value) => {
157+
expect(() => buildUrl(tool, param, value)).toThrow(new RegExp(param))
158+
})
159+
160+
it.each(NEUTRALIZED)('neutralizes %j into a single inert segment', (value) => {
161+
const url = buildUrl(tool, param, value)
162+
const segments = url.pathname.split('/')
163+
164+
expect(url.origin).toBe(baseline.origin)
165+
expect(segments).toHaveLength(baselineSegments.length)
166+
baselineSegments.forEach((segment, index) => {
167+
if (index === slot) return
168+
expect(segments[index]).toBe(segment)
169+
})
170+
expect(url.searchParams.get('foo')).toBeNull()
171+
})
172+
173+
it.each(LEGITIMATE)('passes %j through byte-identical', (value) => {
174+
const url = buildUrl(tool, param, value)
175+
const segments = url.pathname.split('/')
176+
177+
expect(url.origin).toBe(baseline.origin)
178+
expect(segments).toHaveLength(baselineSegments.length)
179+
baselineSegments.forEach((segment, index) => {
180+
expect(index === slot ? decodeURIComponent(segments[index]) : segments[index]).toBe(
181+
index === slot ? value : segment
182+
)
183+
})
184+
})
185+
})
186+
})
187+
})
188+
189+
/**
190+
* The independence check. Poisoning *every* parameter at once passes even when
191+
* a second path parameter is unguarded, because the first guard throws before
192+
* the second is ever reached. Each case below poisons exactly one parameter
193+
* and leaves every other one legitimate.
194+
*/
195+
describe('Algolia guards every path param independently', () => {
196+
describe.each(PATH_TOOLS)('$name', ({ tool, pathParams }) => {
197+
it.each(pathParams)('rejects a bare ".." in %s alone', (param) => {
198+
expect(() => buildUrl(tool, param, '..')).toThrow(new RegExp(param))
199+
})
200+
201+
it.each(pathParams)('keeps the path shape when only %s carries an encoded vector', (param) => {
202+
const baselineSegments = buildUrl(tool).pathname.split('/')
203+
const slot = baselineSegments.indexOf(markerFor(param, (tool.params as any)[param]?.type))
204+
const segments = buildUrl(tool, param, '..%2f..').pathname.split('/')
205+
206+
expect(segments).toHaveLength(baselineSegments.length)
207+
baselineSegments.forEach((segment, index) => {
208+
if (index === slot) return
209+
expect(segments[index]).toBe(segment)
210+
})
211+
})
212+
})
213+
})
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Guards every Box tool that interpolates a parameter into its request path
5+
* against path traversal.
6+
*
7+
* `fileId` and `folderId` are `visibility: 'user-or-llm'`, so prompt injection
8+
* controls them. A value like `..` pops a path segment once `fetch` normalizes
9+
* the URL, re-aiming the request and the caller's OAuth token at a sibling
10+
* endpoint — including on the DELETE routes `box_delete_file` and
11+
* `box_delete_folder`, where `DELETE /2.0/folders/<id>` degrades into a call
12+
* against the collection root.
13+
*
14+
* `encodeURIComponent` is NOT a fix on its own: `.` and `..` are unreserved,
15+
* so they survive encoding untouched and the WHATWG URL parser then removes
16+
* them as dot segments. Only value rejection works. Every assertion below
17+
* resolves the built URL through `new URL(...)` — the same normalization
18+
* `fetch` performs — and compares *segment shape* rather than template text,
19+
* because `pathname.startsWith(prefix)` stays green after a segment is popped.
20+
*/
21+
import { describe, expect, it } from 'vitest'
22+
import * as toolModule from '@/tools/box/index'
23+
import type { ToolConfig } from '@/tools/types'
24+
25+
type AnyTool = ToolConfig<any, any>
26+
27+
/** Vectors the guard must reject outright; no encoding neutralizes them. */
28+
const REJECTED = ['..', '.', ' .. ', 'a/../../b', '\\..\\..'] as const
29+
30+
/**
31+
* Vectors `encodeURIComponent` genuinely does neutralize — `%` and `?` are
32+
* escaped, so the value stays one inert segment. These must NOT throw, and
33+
* they are the vectors that reach a *second* path parameter: a rejected value
34+
* throws at the first guard, masking an unguarded one further along.
35+
*/
36+
const NEUTRALIZED = ['%2e%2e', '..%2f..', 'x?foo=attacker'] as const
37+
38+
/** Values a real caller supplies; every one must survive byte-identical. */
39+
const LEGITIMATE = ['12345678901', '0', '1698765432109', 'f_123-abc', '..foo', 'foo..'] as const
40+
41+
const ID_PREFIX = 'SAFE'
42+
const TOOL_ID_PREFIX = 'box_'
43+
44+
/** No Box path template branches on a parameter value. */
45+
const BRANCH_OVERRIDES: Record<string, Record<string, unknown>> = {}
46+
47+
function isTool(value: unknown): value is AnyTool {
48+
return (
49+
typeof value === 'object' &&
50+
value !== null &&
51+
typeof (value as AnyTool).id === 'string' &&
52+
(value as AnyTool).id.startsWith(TOOL_ID_PREFIX) &&
53+
typeof (value as AnyTool).request?.url === 'function'
54+
)
55+
}
56+
57+
function markerFor(name: string, _type?: string): string {
58+
return `${ID_PREFIX}${name}`
59+
}
60+
61+
/**
62+
* Fills every declared parameter, giving each one a distinct marker so the
63+
* segment it occupies can be located, applies any per-tool overrides needed to
64+
* reach a conditional branch, then overrides exactly one parameter with
65+
* `value` when `poison` names it.
66+
*/
67+
function buildParams(tool: AnyTool, poison?: string, value?: string): Record<string, unknown> {
68+
const params: Record<string, unknown> = {}
69+
for (const [name, def] of Object.entries<any>(tool.params ?? {})) {
70+
const type = def.type
71+
if (type === 'json' || type === 'object') params[name] = {}
72+
else if (type === 'array') params[name] = []
73+
else if (type === 'number') params[name] = Number(markerFor(name, type))
74+
else if (type === 'boolean') params[name] = false
75+
else params[name] = markerFor(name, type)
76+
}
77+
Object.assign(params, BRANCH_OVERRIDES[tool.id] ?? {})
78+
if (poison !== undefined) params[poison] = value
79+
return params
80+
}
81+
82+
function buildUrl(tool: AnyTool, poison?: string, value?: string): URL {
83+
return new URL((tool.request?.url as (p: any) => string)(buildParams(tool, poison, value)))
84+
}
85+
86+
/**
87+
* The parameters this tool interpolates into the PATH. Classification goes
88+
* through `new URL(...).pathname`, so query-zone and host-zone parameters are
89+
* excluded structurally rather than by name.
90+
*/
91+
function pathParamsOf(tool: AnyTool): string[] {
92+
const pathname = buildUrl(tool).pathname
93+
return Object.keys(tool.params ?? {}).filter((name) => {
94+
const def = (tool.params as any)[name]
95+
return pathname.includes(markerFor(name, def?.type))
96+
})
97+
}
98+
99+
const PATH_TOOLS = Object.values(toolModule)
100+
.filter(isTool)
101+
.map((tool) => ({ name: tool.id, tool, pathParams: pathParamsOf(tool) }))
102+
.filter((entry) => entry.pathParams.length > 0)
103+
104+
const TOTAL_PATH_PARAMS = PATH_TOOLS.reduce((sum, entry) => sum + entry.pathParams.length, 0)
105+
106+
describe('Box path-parameter traversal safety', () => {
107+
it('covers every tool that interpolates a parameter into its path', () => {
108+
expect(PATH_TOOLS.length).toBe(7)
109+
expect(TOTAL_PATH_PARAMS).toBe(7)
110+
})
111+
112+
it('leaves the query-zone search parameters out of scope', () => {
113+
const search = PATH_TOOLS.find((entry) => entry.name === 'box_search')
114+
expect(search).toBeUndefined()
115+
})
116+
117+
describe.each(PATH_TOOLS)('$name', ({ tool, pathParams }) => {
118+
const baseline = buildUrl(tool)
119+
const baselineSegments = baseline.pathname.split('/')
120+
121+
describe.each(pathParams)('%s', (param) => {
122+
const slot = baselineSegments.indexOf(markerFor(param, (tool.params as any)[param]?.type))
123+
124+
it('occupies exactly one path segment in the baseline', () => {
125+
expect(slot).toBeGreaterThan(0)
126+
})
127+
128+
it.each(REJECTED)('rejects %j instead of reshaping the path', (value) => {
129+
expect(() => buildUrl(tool, param, value)).toThrow(new RegExp(param))
130+
})
131+
132+
it.each(NEUTRALIZED)('neutralizes %j into a single inert segment', (value) => {
133+
const url = buildUrl(tool, param, value)
134+
const segments = url.pathname.split('/')
135+
136+
expect(url.origin).toBe(baseline.origin)
137+
expect(segments).toHaveLength(baselineSegments.length)
138+
baselineSegments.forEach((segment, index) => {
139+
if (index === slot) return
140+
expect(segments[index]).toBe(segment)
141+
})
142+
expect(url.searchParams.get('foo')).toBeNull()
143+
})
144+
145+
it.each(LEGITIMATE)('passes %j through byte-identical', (value) => {
146+
const url = buildUrl(tool, param, value)
147+
const segments = url.pathname.split('/')
148+
149+
expect(url.origin).toBe(baseline.origin)
150+
expect(segments).toHaveLength(baselineSegments.length)
151+
baselineSegments.forEach((segment, index) => {
152+
expect(index === slot ? decodeURIComponent(segments[index]) : segments[index]).toBe(
153+
index === slot ? value : segment
154+
)
155+
})
156+
})
157+
})
158+
})
159+
})
160+
161+
/**
162+
* The independence check. Poisoning *every* parameter at once passes even when
163+
* a second path parameter is unguarded, because the first guard throws before
164+
* the second is ever reached. Each case below poisons exactly one parameter
165+
* and leaves every other one legitimate.
166+
*/
167+
describe('Box guards every path param independently', () => {
168+
describe.each(PATH_TOOLS)('$name', ({ tool, pathParams }) => {
169+
it.each(pathParams)('rejects a bare ".." in %s alone', (param) => {
170+
expect(() => buildUrl(tool, param, '..')).toThrow(new RegExp(param))
171+
})
172+
173+
it.each(pathParams)('keeps the path shape when only %s carries an encoded vector', (param) => {
174+
const baselineSegments = buildUrl(tool).pathname.split('/')
175+
const slot = baselineSegments.indexOf(markerFor(param, (tool.params as any)[param]?.type))
176+
const segments = buildUrl(tool, param, '..%2f..').pathname.split('/')
177+
178+
expect(segments).toHaveLength(baselineSegments.length)
179+
baselineSegments.forEach((segment, index) => {
180+
if (index === slot) return
181+
expect(segments[index]).toBe(segment)
182+
})
183+
})
184+
})
185+
})

0 commit comments

Comments
 (0)