|
| 1 | +/** |
| 2 | + * Shared harness for the per-service `path_safety.test.ts` suites. |
| 3 | + * |
| 4 | + * Each suite enumerates its service's **(tool, parameter) pairs** from the |
| 5 | + * barrel rather than listing them by hand, so a newly added tool — or a new id |
| 6 | + * parameter on an existing tool — is covered without anyone remembering to |
| 7 | + * register it. |
| 8 | + * |
| 9 | + * Three details are load-bearing, and each exists because an earlier version of |
| 10 | + * this harness got it wrong. |
| 11 | + * |
| 12 | + * **One parameter at a time.** The first version filled *every* string |
| 13 | + * parameter with the same hostile value and swallowed the throw, so the moment |
| 14 | + * one parameter was guarded its siblings stopped being exercised: reverting the |
| 15 | + * guard on `google_drive_unshare`'s `permissionId` while its sibling `fileId` |
| 16 | + * stayed guarded left the suite reporting 285/285 green. Each pair is therefore |
| 17 | + * driven on its own, with every sibling held at a safe value. |
| 18 | + * |
| 19 | + * **Rejection, not shape.** A shape-only assertion is blind to a dot segment in |
| 20 | + * the *final* position: `https://x/a/.` normalizes to `https://x/a/`, which |
| 21 | + * preserves the segment count and every other segment, so the check passes with |
| 22 | + * the guard removed. Tools whose path ends in the guarded id — the Drive |
| 23 | + * `delete_*` family, `box_sign_get_request` — are exactly where that blind spot |
| 24 | + * lives, so every value in {@link MUST_REJECT} is asserted to *throw*. |
| 25 | + * |
| 26 | + * **Every branch.** A parameter that only reaches the path on one branch of a |
| 27 | + * conditional builder is invisible to a single-shot probe. Discovery therefore |
| 28 | + * reads the literals the builder compares against out of its own source and |
| 29 | + * probes each one. |
| 30 | + * |
| 31 | + * Every assertion resolves the built URL with `new URL(...)` — the same |
| 32 | + * normalization `fetch` performs — instead of string-matching the template |
| 33 | + * output. String matching is exactly what let dot-segment traversal through: |
| 34 | + * the template looks correct and the parser rewrites it afterwards. |
| 35 | + */ |
| 36 | +import { getErrorMessage } from '@sim/utils/errors' |
| 37 | +import { expect, it } from 'vitest' |
| 38 | + |
| 39 | +/** |
| 40 | + * The structural shape this harness needs from a tool. |
| 41 | + * |
| 42 | + * Declared locally rather than as `ToolConfig<any, any>` so the harness carries |
| 43 | + * no `any`: the barrels export tools over many different parameter types, and |
| 44 | + * nothing here needs to know any of them beyond "there are declared params and |
| 45 | + * a URL builder". |
| 46 | + */ |
| 47 | +export interface PathTool { |
| 48 | + id: string |
| 49 | + params?: Record<string, { type?: string } | undefined> |
| 50 | + buildUrl: (params: Record<string, unknown>) => string |
| 51 | +} |
| 52 | + |
| 53 | +/** Narrows an unknown barrel export to the shape this harness can drive. */ |
| 54 | +function asPathTool(value: unknown): PathTool | undefined { |
| 55 | + if (typeof value !== 'object' || value === null) return undefined |
| 56 | + |
| 57 | + const candidate = value as { |
| 58 | + id?: unknown |
| 59 | + params?: Record<string, { type?: string } | undefined> |
| 60 | + request?: { url?: unknown } |
| 61 | + } |
| 62 | + |
| 63 | + if (typeof candidate.id !== 'string' || typeof candidate.request?.url !== 'function') { |
| 64 | + return undefined |
| 65 | + } |
| 66 | + |
| 67 | + return { |
| 68 | + id: candidate.id, |
| 69 | + params: candidate.params, |
| 70 | + buildUrl: candidate.request.url as (params: Record<string, unknown>) => string, |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Values that no guard may ever accept, because each one either *is* a dot |
| 76 | + * segment or contains one, and no encoding neutralizes that — the URL parser |
| 77 | + * removes it after decoding. |
| 78 | + * |
| 79 | + * These are asserted to throw. A shape check alone cannot see them when the |
| 80 | + * parameter sits in the final path position. |
| 81 | + */ |
| 82 | +export const MUST_REJECT = [ |
| 83 | + '..', |
| 84 | + '.', |
| 85 | + ' .. ', |
| 86 | + '../', |
| 87 | + './', |
| 88 | + '../../about', |
| 89 | + 'abc/../../../drives', |
| 90 | + 'abc/items/../../../v2/other', |
| 91 | + '\\..\\..', |
| 92 | +] as const |
| 93 | + |
| 94 | +/** |
| 95 | + * Values a guard may legitimately *accept* — percent-encoding renders them |
| 96 | + * inert — but which must never restructure the resolved URL. `%2f` is not |
| 97 | + * decoded before dot segments are removed, and `?`/`#` are escaped, so these |
| 98 | + * survive as opaque text inside one segment. |
| 99 | + */ |
| 100 | +export const MUST_NOT_RESHAPE = [ |
| 101 | + '..%2f..%2fabout', |
| 102 | + 'abc?injectedProbe=attacker', |
| 103 | + 'abc#fragment', |
| 104 | +] as const |
| 105 | + |
| 106 | +/** A single parameter of a single tool that reaches a URL path segment. */ |
| 107 | +export interface PathParam { |
| 108 | + label: string |
| 109 | + tool: PathTool |
| 110 | + paramName: string |
| 111 | + /** |
| 112 | + * The sibling values that make this parameter reach the path — the service's |
| 113 | + * fixed params plus, where the builder branches, the literal that selects the |
| 114 | + * branch the parameter lives on. |
| 115 | + */ |
| 116 | + context: Record<string, unknown> |
| 117 | +} |
| 118 | + |
| 119 | +/** A tool whose URL will not build even from all-safe values. */ |
| 120 | +export interface UnbuildableTool { |
| 121 | + id: string |
| 122 | + reason: string |
| 123 | +} |
| 124 | + |
| 125 | +const SAFE_ID = 'SAFEID' |
| 126 | + |
| 127 | +/** Sentinel for the one parameter under test, so its slots are identifiable. */ |
| 128 | +const PROBE_ID = 'PROBEID' |
| 129 | + |
| 130 | +/** Not a declared parameter — leaves every real one at its safe value. */ |
| 131 | +const ALL_SAFE = '__all_safe__' |
| 132 | + |
| 133 | +/** |
| 134 | + * Fills every declared parameter with a type-appropriate safe value, then |
| 135 | + * overrides the single parameter under test. |
| 136 | + */ |
| 137 | +function buildParams( |
| 138 | + tool: PathTool, |
| 139 | + paramName: string, |
| 140 | + value: string, |
| 141 | + fixed: Record<string, unknown> |
| 142 | +): Record<string, unknown> { |
| 143 | + const params: Record<string, unknown> = {} |
| 144 | + for (const [name, def] of Object.entries(tool.params ?? {})) { |
| 145 | + const type = def?.type |
| 146 | + if (type === 'json' || type === 'array') { |
| 147 | + params[name] = [] |
| 148 | + } else if (type === 'number') { |
| 149 | + params[name] = 1 |
| 150 | + } else if (type === 'boolean') { |
| 151 | + params[name] = false |
| 152 | + } else { |
| 153 | + params[name] = SAFE_ID |
| 154 | + } |
| 155 | + } |
| 156 | + Object.assign(params, fixed) |
| 157 | + params[paramName] = value |
| 158 | + return params |
| 159 | +} |
| 160 | + |
| 161 | +function buildUrl( |
| 162 | + tool: PathTool, |
| 163 | + paramName: string, |
| 164 | + value: string, |
| 165 | + fixed: Record<string, unknown> |
| 166 | +): URL { |
| 167 | + return new URL(tool.buildUrl(buildParams(tool, paramName, value, fixed))) |
| 168 | +} |
| 169 | + |
| 170 | +/** |
| 171 | + * Harvests the string literals a URL builder compares against, straight from |
| 172 | + * its own source. |
| 173 | + * |
| 174 | + * Some builders put a path parameter on only one branch of a conditional — a |
| 175 | + * second identifier that appears only when `action === 'unblock'`, say — and |
| 176 | + * the discriminating parameter often declares no enum, only prose in its |
| 177 | + * description. Probing with a single default value never enters that branch, so |
| 178 | + * the parameter is invisible to discovery and silently untested. |
| 179 | + * |
| 180 | + * Reading the comparands out of the function source means every branch is |
| 181 | + * probed, and a branch added later is picked up without editing any test. |
| 182 | + */ |
| 183 | +function branchLiterals(tool: PathTool): string[] { |
| 184 | + const source = String(tool.buildUrl) |
| 185 | + const literals = new Set<string>() |
| 186 | + |
| 187 | + for (const pattern of [ |
| 188 | + /[=!]==\s*['"`]([^'"`\n]{1,64})['"`]/g, |
| 189 | + /['"`]([^'"`\n]{1,64})['"`]\s*[=!]==/g, |
| 190 | + /case\s+['"`]([^'"`\n]{1,64})['"`]/g, |
| 191 | + ]) { |
| 192 | + for (const match of source.matchAll(pattern)) literals.add(match[1]) |
| 193 | + } |
| 194 | + |
| 195 | + return [...literals] |
| 196 | +} |
| 197 | + |
| 198 | +/** |
| 199 | + * Enumerates every (tool, parameter) pair of a service whose value lands in a |
| 200 | + * URL **path** segment. |
| 201 | + * |
| 202 | + * A parameter that only ever reaches the query string, or a tool with a static |
| 203 | + * URL, is not in this risk class and is left out — the probe decides that by |
| 204 | + * looking for the sentinel in `pathname`, never in the full URL. |
| 205 | + */ |
| 206 | +export function discoverPathParams( |
| 207 | + barrel: Record<string, unknown>, |
| 208 | + idPrefix: string, |
| 209 | + fixed: Record<string, unknown> = {} |
| 210 | +): { covered: PathParam[]; unbuildable: UnbuildableTool[] } { |
| 211 | + const covered: PathParam[] = [] |
| 212 | + const unbuildable: UnbuildableTool[] = [] |
| 213 | + |
| 214 | + for (const exported of Object.values(barrel)) { |
| 215 | + const tool = asPathTool(exported) |
| 216 | + if (!tool || !tool.id.startsWith(idPrefix)) continue |
| 217 | + |
| 218 | + const names = Object.keys(tool.params ?? {}).filter((name) => !(name in fixed)) |
| 219 | + |
| 220 | + /** |
| 221 | + * Every sibling assignment worth probing: the plain one, then each |
| 222 | + * parameter pinned to each literal the builder branches on. |
| 223 | + */ |
| 224 | + const branches: Record<string, unknown>[] = [{}] |
| 225 | + for (const literal of branchLiterals(tool)) { |
| 226 | + for (const name of names) branches.push({ [name]: literal }) |
| 227 | + } |
| 228 | + |
| 229 | + /** |
| 230 | + * Buildability is decided from an all-safe build, independent of the |
| 231 | + * per-parameter probes. A probe is *meant* to throw for a guarded |
| 232 | + * parameter, so treating a failed probe as an unbuildable tool would make |
| 233 | + * this list noisy; but a tool whose URL will not build at all must never |
| 234 | + * vanish from coverage silently. |
| 235 | + */ |
| 236 | + let buildable = false |
| 237 | + let firstFailure = '' |
| 238 | + for (const branch of branches) { |
| 239 | + try { |
| 240 | + buildUrl(tool, ALL_SAFE, SAFE_ID, { ...fixed, ...branch }) |
| 241 | + buildable = true |
| 242 | + break |
| 243 | + } catch (error) { |
| 244 | + if (!firstFailure) firstFailure = getErrorMessage(error, 'unknown error') |
| 245 | + } |
| 246 | + } |
| 247 | + |
| 248 | + if (!buildable) unbuildable.push({ id: tool.id, reason: firstFailure || 'URL did not build' }) |
| 249 | + |
| 250 | + for (const name of names) { |
| 251 | + let match: Record<string, unknown> | undefined |
| 252 | + |
| 253 | + for (const branch of branches) { |
| 254 | + if (name in branch) continue |
| 255 | + const context = { ...fixed, ...branch } |
| 256 | + try { |
| 257 | + if (buildUrl(tool, name, PROBE_ID, context).pathname.includes(PROBE_ID)) { |
| 258 | + match = context |
| 259 | + break |
| 260 | + } |
| 261 | + } catch { |
| 262 | + // A guarded parameter is expected to throw for some probes; another |
| 263 | + // branch may still reach it, so keep going. |
| 264 | + } |
| 265 | + } |
| 266 | + |
| 267 | + if (match) { |
| 268 | + covered.push({ label: `${tool.id} :: ${name}`, tool, paramName: name, context: match }) |
| 269 | + } |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + return { covered, unbuildable } |
| 274 | +} |
| 275 | + |
| 276 | +/** |
| 277 | + * Normalizes an error message and a parameter name to bare lowercase letters so |
| 278 | + * a guard can be credited with naming its parameter however it spells it. |
| 279 | + * |
| 280 | + * A few parameters are refused by a stricter service-specific validator that |
| 281 | + * predates these guards and spells the name in prose — Supabase's |
| 282 | + * `functionName` is reported as *"Invalid function name"*. That is an equally |
| 283 | + * correct outcome and should still count as naming the offender, so both sides |
| 284 | + * are stripped of non-letters before the comparison. |
| 285 | + */ |
| 286 | +function namesParam(message: string, paramName: string): boolean { |
| 287 | + const strip = (text: string) => text.toLowerCase().replaceAll(/[^a-z]/g, '') |
| 288 | + return strip(message).includes(strip(paramName)) |
| 289 | +} |
| 290 | + |
| 291 | +export interface TraversalOptions { |
| 292 | + origin: string |
| 293 | + /** The fixed API prefix every route of the service shares. */ |
| 294 | + basePath: string |
| 295 | +} |
| 296 | + |
| 297 | +/** Asserts the traversal invariant for one (tool, parameter) pair. */ |
| 298 | +export function itResistsTraversal( |
| 299 | + { tool, paramName, context }: PathParam, |
| 300 | + { origin, basePath }: TraversalOptions |
| 301 | +): void { |
| 302 | + const baselinePath = buildUrl(tool, paramName, PROBE_ID, context).pathname |
| 303 | + const prefix = baselinePath.split('/').slice(0, baselinePath.split('/').indexOf(PROBE_ID)) |
| 304 | + |
| 305 | + it('stays under the service API prefix', () => { |
| 306 | + expect(baselinePath.startsWith(basePath)).toBe(true) |
| 307 | + }) |
| 308 | + |
| 309 | + /** |
| 310 | + * The rejection assertion, not a shape assertion. A trailing `.` preserves |
| 311 | + * the shape of the resolved path exactly, so only "did it throw" can see it. |
| 312 | + */ |
| 313 | + it.each(MUST_REJECT)('rejects %j outright, naming the parameter', (value) => { |
| 314 | + let message = '' |
| 315 | + try { |
| 316 | + buildUrl(tool, paramName, value, context) |
| 317 | + } catch (error) { |
| 318 | + message = getErrorMessage(error, 'unknown error') |
| 319 | + } |
| 320 | + |
| 321 | + expect(message, `${paramName} accepted ${JSON.stringify(value)}`).not.toBe('') |
| 322 | + expect(namesParam(message, paramName), `error did not name ${paramName}: ${message}`).toBe(true) |
| 323 | + }) |
| 324 | + |
| 325 | + it.each(MUST_NOT_RESHAPE)('renders %j inert without reshaping the path', (value) => { |
| 326 | + let url: URL |
| 327 | + try { |
| 328 | + url = buildUrl(tool, paramName, value, context) |
| 329 | + } catch { |
| 330 | + return |
| 331 | + } |
| 332 | + |
| 333 | + expect(url.origin).toBe(origin) |
| 334 | + expect(url.pathname.startsWith(basePath)).toBe(true) |
| 335 | + |
| 336 | + const segments = url.pathname.split('/') |
| 337 | + expect(segments.slice(0, prefix.length)).toEqual(prefix) |
| 338 | + expect(segments).not.toContain('..') |
| 339 | + expect(segments).not.toContain('.') |
| 340 | + expect(url.searchParams.get('injectedProbe')).toBeNull() |
| 341 | + }) |
| 342 | + |
| 343 | + /** |
| 344 | + * Padding must never change which resource is addressed. Refusing it outright |
| 345 | + * is an equally correct outcome — `validateDatabaseIdentifier` guards |
| 346 | + * Supabase's `table` and deliberately admits no whitespace at all — so the |
| 347 | + * assertion is "same path or no path", not "always trims". |
| 348 | + */ |
| 349 | + it('does not let surrounding whitespace alter the value', () => { |
| 350 | + let url: URL |
| 351 | + try { |
| 352 | + url = buildUrl(tool, paramName, ` ${PROBE_ID} `, context) |
| 353 | + } catch { |
| 354 | + return |
| 355 | + } |
| 356 | + |
| 357 | + expect(url.pathname).toBe(baselinePath) |
| 358 | + }) |
| 359 | +} |
| 360 | + |
| 361 | +/** |
| 362 | + * Asserts that real-world values reach the wire byte-for-byte, so a guard can |
| 363 | + * never be tightened into breaking legitimate callers. |
| 364 | + */ |
| 365 | +export function itPassesLegitimateValues( |
| 366 | + { tool, paramName, context }: PathParam, |
| 367 | + { values, fixed = {} }: { values: readonly string[]; fixed?: Record<string, unknown> } |
| 368 | +): void { |
| 369 | + const merged = { ...context, ...fixed } |
| 370 | + const baseline = buildUrl(tool, paramName, PROBE_ID, merged).pathname |
| 371 | + |
| 372 | + it.each(values)('passes %j through unchanged', (value) => { |
| 373 | + expect(decodeURIComponent(buildUrl(tool, paramName, value, merged).pathname)).toBe( |
| 374 | + decodeURIComponent(baseline).split(PROBE_ID).join(value) |
| 375 | + ) |
| 376 | + }) |
| 377 | +} |
0 commit comments