|
| 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 | +}) |
0 commit comments