Skip to content

Commit d54bcee

Browse files
committed
fix(tools): reject values that cannot be a path segment
toGuardedString coerced with String(value), so an object reached the wire as %5Bobject%20Object%5D and a boolean as 'true' -- a doomed request instead of a clean error, on 44 live call sites. Accepts string, bigint, and finite non-exponential numbers; everything else throws a named error. Rejects a number whose decimal text is a rewrite rather than the caller's value: 1e21 stringifies to '1e+21', and an integer past 2^53 has already lost digits. A snowflake cannot be repaired here at all -- JSON.parse destroys it before this runs -- so the doc now says it must arrive as a string, and cites Box folderId (root = 0) instead. Corrects the claim that the parser removes only an exact '.' or '..'; the spec defines 11 removable spellings. The guards are sufficient because encodeURIComponent escapes '%', not because the others cannot occur.
1 parent e4166ec commit d54bcee

2 files changed

Lines changed: 303 additions & 42 deletions

File tree

apps/sim/tools/url-path.test.ts

Lines changed: 187 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@ describe('the premise these helpers exist for', () => {
4040
expect(new URL('https://x/v1/a/b/..').pathname).toBe('/v1/a/')
4141
expect(new URL('https://x/v1/a/b/%2e%2e').pathname).toBe('/v1/a/')
4242
})
43+
44+
/**
45+
* The removable set is the ELEVEN spellings the URL Standard defines, not
46+
* just the two literal ones. The helpers below match only the literal
47+
* spellings, which is sufficient solely because `encodeURIComponent` escapes
48+
* `%` and so can never emit a `%2e` form. Both halves are asserted here,
49+
* because the second is what makes the first safe.
50+
*/
51+
it.concurrent.each([
52+
['.', '/v1/a/'],
53+
['%2e', '/v1/a/'],
54+
['%2E', '/v1/a/'],
55+
['..', '/v1/'],
56+
['.%2e', '/v1/'],
57+
['.%2E', '/v1/'],
58+
['%2e.', '/v1/'],
59+
['%2E.', '/v1/'],
60+
['%2e%2e', '/v1/'],
61+
['%2E%2E', '/v1/'],
62+
['%2e%2E', '/v1/'],
63+
] as const)(
64+
'the parser also removes the encoded dot-segment spelling %j (=> %j)',
65+
(spelling, expected) => {
66+
expect(new URL(`https://x/v1/a/${spelling}`).pathname).toBe(expected)
67+
}
68+
)
69+
70+
it.concurrent.each(['...', '%2e%2e%2e', '%252e', 'a%2e'])(
71+
'the parser does NOT remove %j',
72+
(spelling) => {
73+
expect(new URL(`https://x/v1/a/${spelling}`).pathname.startsWith('/v1/a/')).toBe(true)
74+
}
75+
)
76+
77+
it.concurrent('encodeURIComponent escapes % so no helper can emit a %2e spelling', () => {
78+
expect(encodeURIComponent('%2e%2e')).toBe('%252e%252e')
79+
expect(new URL('https://x/v1/a/%252e%252e').pathname).toBe('/v1/a/%252e%252e')
80+
})
4381
})
4482

4583
describe('safeUrlPathSegment', () => {
@@ -212,8 +250,10 @@ describe('safeUrlPath', () => {
212250

213251
/**
214252
* Dropping the per-segment trim must not re-open traversal. The WHATWG
215-
* parser only removes a segment that is *exactly* `.` or `..`; a padded one
216-
* encodes to inert text and stays put.
253+
* parser removes a segment only when the whole segment spells a dot segment
254+
* — literally or percent-encoded — and a padded one spells neither, so it
255+
* encodes to inert text and stays put. The percent-encoded spellings are
256+
* unreachable from here because `encodeURIComponent` escapes `%` itself.
217257
*/
218258
it.concurrent.each(['a/ .. /b', 'a/ ../b', 'a/.. /b', 'a/ . /b'] as const)(
219259
'keeps the padded dot segment %j inert instead of popping a segment',
@@ -346,8 +386,10 @@ describe('safeUrlPath options', () => {
346386
})
347387

348388
/**
349-
* Neither option touches the dot-segment check, which is an EXACT match on
350-
* the segment — the only spelling the WHATWG parser actually removes.
389+
* Neither option touches the dot-segment check. An exact match on the raw
390+
* segment suffices because every segment then goes through
391+
* `encodeURIComponent`, which escapes `%` and so cannot emit the
392+
* percent-encoded dot-segment spellings the parser also removes.
351393
*/
352394
it.concurrent.each(['a//../b', '/..', '../', '..', '.', 'a/../b', '/a/./b', '..//..'] as const)(
353395
'still rejects the dot segment in %j under storage options',
@@ -440,3 +482,144 @@ describe('safeOpaqueUrlSegment', () => {
440482
expect(safeOpaqueUrlSegment('foo/bar', 'objectID')).toBe('foo%2Fbar')
441483
})
442484
})
485+
486+
/**
487+
* The coercion is deliberately narrow. It exists so an id the caller genuinely
488+
* supplied as a JSON number is not reported as missing, and it must not be a
489+
* general `String(value)` — that turns a wrong-shaped value into a plausible
490+
* but wrong path segment instead of a clean, named error.
491+
*/
492+
describe('coercion boundary', () => {
493+
const HELPERS = [
494+
['safeUrlPathSegment', safeUrlPathSegment],
495+
['safeUrlPath', safeUrlPath],
496+
['safeOpaqueUrlSegment', safeOpaqueUrlSegment],
497+
] as const
498+
499+
it.concurrent.each([
500+
['string', 'abc', 'abc'],
501+
['zero', 0, '0'],
502+
['negative', -7, '-7'],
503+
['decimal', 1.5, '1.5'],
504+
['bigint', 42n, '42'],
505+
['large safe integer', 9007199254740991, '9007199254740991'],
506+
] as const)('accepts the %s as the expected string', (_label, value, expected) => {
507+
for (const [name, helper] of HELPERS) {
508+
expect(`${name}:${helper(value as never, 'id')}`).toBe(`${name}:${expected}`)
509+
}
510+
})
511+
512+
it.concurrent.each([
513+
['plain object', {}],
514+
['populated object', { a: 1 }],
515+
['Map', new Map()],
516+
['null-prototype object', Object.create(null)],
517+
['true', true],
518+
['false', false],
519+
['NaN', Number.NaN],
520+
['Infinity', Number.POSITIVE_INFINITY],
521+
['-Infinity', Number.NEGATIVE_INFINITY],
522+
['array', [1, 2]],
523+
['Date', new Date(0)],
524+
['symbol', Symbol('s')],
525+
['exponential number', 1e21],
526+
['unsafe integer', Number.MAX_SAFE_INTEGER + 2],
527+
['snowflake-sized id parsed as a number', Number('1234567890123456789')],
528+
['function', () => 'x'],
529+
] as const)('rejects the %s with an error naming the param', (_label, value) => {
530+
for (const [name, helper] of HELPERS) {
531+
let thrown: unknown = null
532+
try {
533+
helper(value as never, 'objectId')
534+
} catch (error) {
535+
thrown = error
536+
}
537+
expect(`${name}:${thrown instanceof Error}`).toBe(`${name}:true`)
538+
expect(`${name}:${(thrown as Error).message}`).toContain('objectId')
539+
expect((thrown as Error).message).not.toContain('[object')
540+
expect((thrown as Error).message).not.toMatch(/No default value/)
541+
}
542+
})
543+
544+
/**
545+
* `null` and `undefined` keep reporting *"is required"* — the distinction
546+
* between "you sent nothing" and "you sent the wrong kind of thing" is what
547+
* makes the error actionable.
548+
*/
549+
it.concurrent.each([
550+
['null', null],
551+
['undefined', undefined],
552+
] as const)('keeps the required error for %s rather than the invalid-value one', (_l, value) => {
553+
for (const [name, helper] of HELPERS) {
554+
expect(() => helper(value as never, 'objectId')).toThrow(/objectId is required/)
555+
expect(`${name}`).toBe(name)
556+
}
557+
})
558+
559+
it.concurrent('never lets a rejected value reach the built path', () => {
560+
for (const value of [{}, true, Number.NaN, [1, 2], 1e21, new Date(0)]) {
561+
let built: string | null = null
562+
try {
563+
built = `${ORIGIN}/v1/${safeUrlPathSegment(value as never, 'id')}`
564+
} catch {
565+
continue
566+
}
567+
expect(built).toBeNull()
568+
}
569+
})
570+
})
571+
572+
/**
573+
* The 44 live call sites (Vercel x43, Daytona x1) pass provider ids and
574+
* hostnames as strings, occasionally a numeric id. Their output must be
575+
* byte-identical across this change.
576+
*/
577+
describe('live call-site values', () => {
578+
it.concurrent.each([
579+
'prj_2rXy9Qh0lE8vJmKpZ4aB1cD',
580+
'dpl_9fJk2LmN4pQr7sT1uV3wX5yZ',
581+
'team_abcDEF123',
582+
'my-app.vercel.app',
583+
'example.com',
584+
'sub.domain.example.co.uk',
585+
'rec_1a2b3c',
586+
'ecfg_xyz',
587+
'3f1c9a1e-6f27-4b2e-9b0f-2a1d4e5c6b7a',
588+
])('passes %j through unchanged', (value) => {
589+
expect(safeUrlPathSegment(value, 'id')).toBe(value)
590+
})
591+
592+
it.concurrent('still stringifies a numeric id', () => {
593+
expect(safeUrlPathSegment(2487956, 'woeid')).toBe('2487956')
594+
expect(safeUrlPathSegment(0, 'folderId')).toBe('0')
595+
})
596+
})
597+
598+
/**
599+
* The rejection set is NOT option-invariant: `' .. '` trims to the exact dot
600+
* segment under the default and is therefore rejected, while
601+
* `preserveOuterWhitespace` keeps it as inert `%20..%20` text that the WHATWG
602+
* parser leaves in place.
603+
*/
604+
describe('preserveOuterWhitespace changes the rejection set', () => {
605+
it.concurrent('rejects " .. " by default but accepts it as inert text with the option', () => {
606+
expect(() => safeUrlPath(' .. ', 'path')).toThrow(/path/)
607+
expect(safeUrlPath(' .. ', 'path', { preserveOuterWhitespace: true })).toBe('%20..%20')
608+
609+
const url = new URL(
610+
`${ORIGIN}/storage/v1/object/bkt/${safeUrlPath(' .. ', 'path', { preserveOuterWhitespace: true })}`
611+
)
612+
expect(url.pathname).toBe('/storage/v1/object/bkt/%20..%20')
613+
expect(url.pathname).not.toContain('/../')
614+
})
615+
616+
it.concurrent('rejects " . " by default but keeps it inert with the option', () => {
617+
expect(() => safeUrlPath(' . ', 'path')).toThrow(/path/)
618+
expect(safeUrlPath(' . ', 'path', { preserveOuterWhitespace: true })).toBe('%20.%20')
619+
})
620+
621+
it.concurrent('still rejects an untrimmed exact dot segment under the option', () => {
622+
expect(() => safeUrlPath('..', 'path', { preserveOuterWhitespace: true })).toThrow(/path/)
623+
expect(() => safeUrlPath('a/../b', 'path', { preserveOuterWhitespace: true })).toThrow(/path/)
624+
})
625+
})

0 commit comments

Comments
 (0)