fix(query): use Object.create(null) to prevent prototype pollution#2661
Conversation
✅ Deploy Preview for vue-router canceled.
|
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds an experimental query parsing/normalization API and an experimental URL parser, wires experimental modules to use them, updates several import paths, adds Vitest tests for the new experimental APIs, and leaves a TODO in the stable ChangesExperimental Query & URL parsing
Sequence DiagramsequenceDiagram
participant App as Application
participant URL as experimental_parseURL
participant QP as experimental_parseQuery
participant NQ as experimental_normalizeQuery
participant Resolver as Route Resolver
App->>URL: parseURL("/path?x=1&x=2#h")
URL->>URL: split path, search, hash
URL->>QP: parseQuery("x=1&x=2")
QP->>QP: decode, replace '+', aggregate keys
QP-->>URL: { x: ["1","2"] }
URL->>URL: resolve relative path (if currentLocation provided)
URL-->>App: { fullPath, path, query, hash }
App->>Resolver: resolve route with raw query
Resolver->>NQ: normalizeQuery(rawQuery)
NQ->>NQ: coerce values, create null-prototype object
NQ-->>Resolver: safe `LocationQuery`
Resolver-->>App: resolved route
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/router/src/experimental/router.spec.ts (1)
1068-1085:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winThe
inc-query-hashredirect at line 266 relies on implicit array-to-string coercion that would silently misbehave for multi-valued params.
to.query.nis now(string | null)[], soto.query.n + '-2'coerces the array to a string:['3'] + '-2'='3-2'. This produces the expected['3-2']after normalization. However, ifnappeared twice in the URL,['3','5'] + '-2'='3,5-2'— silently incorrect. The redirect handler should explicitly access the first element:- query: { n: to.query.n + '-2' }, + query: { n: (to.query.n?.[0] ?? '') + '-2' },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/router/src/experimental/router.spec.ts` around lines 1068 - 1085, The redirect for route "inc-query-hash" is concatenating to.query.n (which is a (string|null)[]), causing array-to-string coercion for multi-valued params; update the redirect handler for the "inc-query-hash" route to explicitly read the first element (e.g., use to.query.n?.[0] or similar null-safe access) before appending "-2" and building the new query/hash so multi-valued n values aren't silently joined.packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts (1)
89-102:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
OPTIONAL_NUMBER_QUERY_MATCHER.matchdiverges from its intendednullbehavior whenquery.countis[null].With
LocationQuery = Record<string, (string | null)[]>,query.countis now(string | null)[] | undefined. The checkif (!query.count)handlesundefinedcorrectly but treats[null](produced by?countwith no value) as truthy — a non-empty array is always truthy.Number([null])evaluates to0(via[null].toString() → ''), so the matcher returns{ count: 0 }rather than the intended{ count: null }.The matcher needs to unpack the array value before processing:
match(query) { - if (!query.count) return { count: null } - const count = Number(query.count) + const raw = query.count?.[0] + if (raw == null) return { count: null } + const count = Number(raw) if (Number.isNaN(count)) miss('Count must be a number') return { count } },Similar adjustments are needed for any test utility that reads from
query.*as a scalar now that the experimentalLocationQueryalways uses arrays.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts` around lines 89 - 102, The match function in OPTIONAL_NUMBER_QUERY_MATCHER treats query.count as a scalar but LocationQuery now makes it (string|null)[]|undefined, so modify OPTIONAL_NUMBER_QUERY_MATCHER.match to first unpack the array (e.g., const raw = Array.isArray(query.count) ? query.count[0] : query.count), treat raw === null or raw === '' as the intended null case, then parse Number(raw) and call miss on NaN; also update any test utilities that read query.* as scalars to unpack the first array element before processing to preserve the original null/empty behavior.
🧹 Nitpick comments (1)
packages/router/src/experimental/query.ts (1)
72-78: ⚡ Quick winAdd an own-property guard to
experimental_normalizeQueryto match the PR's hardening intent.
for...initerates inherited enumerable properties. IfObject.prototypehas been poisoned with an enumerable key before this function runs, that key would be silently included in the normalized output — the opposite of what this PR aims to prevent.🛡️ Proposed fix
for (var key in query) { + if (!Object.hasOwn(query, key)) continue var value = query[key]Alternatively, replace the loop with
Object.keys(query)/Object.entries(query)to iterate only own enumerable properties.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/router/src/experimental/query.ts` around lines 72 - 78, The for...in loop in experimental_normalizeQuery can include inherited enumerable properties; guard against that by only processing own properties (e.g., add an hasOwnProperty check inside the loop or switch to iterating Object.keys(query)/Object.entries(query)). Locate the loop that reads "for (var key in query)" in experimental_normalizeQuery, skip keys where !Object.prototype.hasOwnProperty.call(query, key), and keep the existing normalization logic (isArray branch and value coercion) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/router/src/experimental/data-loaders/utils.ts`:
- Line 1: The TypeScript error comes from using the experimental LocationQuery
type in isSubsetOf while callers (e.g., RouteLocationNormalizedLoaded.to.query)
use the non-experimental shape; fix by widening isSubsetOf's parameter types to
accept the non-experimental query shape (use Record<string, string | null |
(string | null)[]> or import the LocationQuery type used by
RouteLocationNormalizedLoaded) so callers like to.query type-check; update the
type annotations in isSubsetOf (and any helper types in utils.ts) to the broader
union and adjust any internal handling to treat single string/null values as
single-item arrays if needed.
In `@packages/router/src/experimental/location.spec.ts`:
- Around line 8-236: Add explicit assertions in the location.spec tests to
verify the security invariant that parseURL returns a query object with a null
prototype; locate the parseURL expectations (e.g., in the tests like "works with
no query no hash", "extracts the query", and other cases referencing parseURL)
and after each expect(parseURL(...)).toEqual(...) add an additional assertion
that Object.getPrototypeOf(result.query) === null (or use
expect(Object.getPrototypeOf(result.query)).toBeNull()) so every relevant test
verifies the query object's prototype is null.
In `@packages/router/src/experimental/query.spec.ts`:
- Around line 18-23: Update the test for empty-string parsing to also assert the
returned object's prototype is null: after calling parseQuery('') and
parseQuery('?') (variables emptyQuery and the direct call), add assertions using
Object.getPrototypeOf(...) toBeNull (or equivalent) so the spec checks both
emptiness and the null-prototype hardening for parseQuery.
In `@packages/router/src/experimental/route-resolver/resolver-fixed.ts`:
- Around line 1-6: After switching to experimental normalizeQuery/parseQuery the
merged url.query can contain scalar strings because query.build(params) returns
plain strings that Object.assign overwrites array values with; to fix, after
merging call experimental_normalizeQuery (normalizeQuery) on the final url.query
to ensure every value is an array (or alternatively change
MatcherPatternQuery.build to return array values), i.e. locate the code that
does Object.assign into url.query and either adjust MatcherPatternQuery.build to
emit (string|null)[] for keys or simply post-process the merged object with
normalizeQuery(url.query) before using url.query and before calling query.match
so the shape always matches LocationQuery.
In `@packages/router/src/experimental/router.ts`:
- Around line 25-26: The EXPR option parseQuery declared on
EXPERIMENTAL_RouterOptions_Base (typed via originalParseQuery) is not used:
parseQuery is only a type (originalParseQuery) while its destructuring from
options (parseQuery) is commented out and resolver-fixed.ts hard-codes
experimental_parseQuery, so providing the option has no effect; fix by either
wiring the option through or warning—uncomment and reintroduce the parseQuery
destructuring in the router factory (so options.parseQuery is captured), change
resolver-fixed.ts to accept and call the provided parseQuery instead of using
experimental_parseQuery directly (fall back to experimental_parseQuery when
options.parseQuery is undefined), and add a __DEV__ warning if parseQuery is
present but not supported; alternatively remove parseQuery from
EXPERIMENTAL_RouterOptions_Base if you don’t intend to support it.
---
Outside diff comments:
In `@packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts`:
- Around line 89-102: The match function in OPTIONAL_NUMBER_QUERY_MATCHER treats
query.count as a scalar but LocationQuery now makes it
(string|null)[]|undefined, so modify OPTIONAL_NUMBER_QUERY_MATCHER.match to
first unpack the array (e.g., const raw = Array.isArray(query.count) ?
query.count[0] : query.count), treat raw === null or raw === '' as the intended
null case, then parse Number(raw) and call miss on NaN; also update any test
utilities that read query.* as scalars to unpack the first array element before
processing to preserve the original null/empty behavior.
In `@packages/router/src/experimental/router.spec.ts`:
- Around line 1068-1085: The redirect for route "inc-query-hash" is
concatenating to.query.n (which is a (string|null)[]), causing array-to-string
coercion for multi-valued params; update the redirect handler for the
"inc-query-hash" route to explicitly read the first element (e.g., use
to.query.n?.[0] or similar null-safe access) before appending "-2" and building
the new query/hash so multi-valued n values aren't silently joined.
---
Nitpick comments:
In `@packages/router/src/experimental/query.ts`:
- Around line 72-78: The for...in loop in experimental_normalizeQuery can
include inherited enumerable properties; guard against that by only processing
own properties (e.g., add an hasOwnProperty check inside the loop or switch to
iterating Object.keys(query)/Object.entries(query)). Locate the loop that reads
"for (var key in query)" in experimental_normalizeQuery, skip keys where
!Object.prototype.hasOwnProperty.call(query, key), and keep the existing
normalization logic (isArray branch and value coercion) unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4cdd1e18-f895-4bc7-9a96-203ac0104150
📒 Files selected for processing (13)
packages/router/src/experimental/data-loaders/defineColadaLoader.tspackages/router/src/experimental/data-loaders/utils.tspackages/router/src/experimental/location.spec.tspackages/router/src/experimental/location.tspackages/router/src/experimental/query.spec.tspackages/router/src/experimental/query.tspackages/router/src/experimental/route-resolver/resolver-abstract.tspackages/router/src/experimental/route-resolver/resolver-fixed.spec.tspackages/router/src/experimental/route-resolver/resolver-fixed.tspackages/router/src/experimental/router.spec.tspackages/router/src/experimental/router.tspackages/router/src/location.tspackages/router/src/query.ts
✅ Files skipped from review due to trivial changes (3)
- packages/router/src/experimental/data-loaders/defineColadaLoader.ts
- packages/router/src/location.ts
- packages/router/src/query.ts
| it('works with no query no hash', () => { | ||
| expect(parseURL('/foo')).toEqual({ | ||
| fullPath: '/foo', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with partial path with no query', () => { | ||
| expect(parseURL('foo#hash')).toEqual({ | ||
| fullPath: '/foo#hash', | ||
| path: '/foo', | ||
| hash: '#hash', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with partial path', () => { | ||
| expect(parseURL('foo?f=foo#hash')).toEqual({ | ||
| fullPath: '/foo?f=foo#hash', | ||
| path: '/foo', | ||
| hash: '#hash', | ||
| query: { f: ['foo'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with only query', () => { | ||
| expect(parseURL('?f=foo')).toEqual({ | ||
| fullPath: '/?f=foo', | ||
| path: '/', | ||
| hash: '', | ||
| query: { f: ['foo'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with only hash', () => { | ||
| expect(parseURL('#foo')).toEqual({ | ||
| fullPath: '/#foo', | ||
| path: '/', | ||
| hash: '#foo', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with partial path and current location', () => { | ||
| expect(parseURL('foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/foo', | ||
| path: '/parent/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with partial path with query and hash and current location', () => { | ||
| expect(parseURL('foo?f=foo#hash', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/foo?f=foo#hash', | ||
| path: '/parent/foo', | ||
| hash: '#hash', | ||
| query: { f: ['foo'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with relative query and current location', () => { | ||
| expect(parseURL('?f=foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/bar?f=foo', | ||
| path: '/parent/bar', | ||
| hash: '', | ||
| query: { f: ['foo'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with relative hash and current location', () => { | ||
| expect(parseURL('#hash', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/bar#hash', | ||
| path: '/parent/bar', | ||
| hash: '#hash', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('extracts the query', () => { | ||
| expect(parseURL('/foo?a=one&b=two')).toEqual({ | ||
| fullPath: '/foo?a=one&b=two', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: { | ||
| a: ['one'], | ||
| b: ['two'], | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| it('extracts the hash', () => { | ||
| expect(parseURL('/foo#bar')).toEqual({ | ||
| fullPath: '/foo#bar', | ||
| path: '/foo', | ||
| hash: '#bar', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('extracts query and hash', () => { | ||
| expect(parseURL('/foo?a=one#bar')).toEqual({ | ||
| fullPath: '/foo?a=one#bar', | ||
| path: '/foo', | ||
| hash: '#bar', | ||
| query: { a: ['one'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('extracts multiple query parameters as an array', () => { | ||
| expect(parseURL('/foo?a=one&a=two&a=three')).toEqual({ | ||
| fullPath: '/foo?a=one&a=two&a=three', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: { a: ['one', 'two', 'three'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('correctly parses a ? after the hash', () => { | ||
| expect(parseURL('/foo#?a=one')).toEqual({ | ||
| fullPath: '/foo#?a=one', | ||
| path: '/foo', | ||
| hash: '#?a=one', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('/foo/?a=two#?a=one')).toEqual({ | ||
| fullPath: '/foo/?a=two#?a=one', | ||
| path: '/foo/', | ||
| hash: '#?a=one', | ||
| query: { a: ['two'] }, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with empty query', () => { | ||
| expect(parseURL('/foo?#hash')).toEqual({ | ||
| fullPath: '/foo?#hash', | ||
| path: '/foo', | ||
| hash: '#hash', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('/foo#hash')).toEqual({ | ||
| fullPath: '/foo#hash', | ||
| path: '/foo', | ||
| hash: '#hash', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('/foo?')).toEqual({ | ||
| fullPath: '/foo?', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('/foo')).toEqual({ | ||
| fullPath: '/foo', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with empty hash', () => { | ||
| expect(parseURL('/foo#')).toEqual({ | ||
| fullPath: '/foo#', | ||
| path: '/foo', | ||
| hash: '#', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('/foo?#')).toEqual({ | ||
| fullPath: '/foo?#', | ||
| path: '/foo', | ||
| hash: '#', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('/foo')).toEqual({ | ||
| fullPath: '/foo', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| }) | ||
|
|
||
| it('works with a relative paths', () => { | ||
| expect(parseURL('foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/foo', | ||
| path: '/parent/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('./foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/foo', | ||
| path: '/parent/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('../foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/foo', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| // cannot go below root | ||
| expect(parseURL('../../foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/foo', | ||
| path: '/foo', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
|
|
||
| expect(parseURL('', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/bar', | ||
| path: '/parent/bar', | ||
| hash: '', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('#foo', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/bar#foo', | ||
| path: '/parent/bar', | ||
| hash: '#foo', | ||
| query: {}, | ||
| }) | ||
| expect(parseURL('?o=o', '/parent/bar')).toEqual({ | ||
| fullPath: '/parent/bar?o=o', | ||
| path: '/parent/bar', | ||
| hash: '', | ||
| query: { o: ['o'] }, | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Assert null-prototype on query in parseURL outputs to lock the security fix.
Given the PR objective, these cases should explicitly verify result.query has a null prototype (not only shape/values). Right now the security invariant isn’t directly asserted in this suite.
Suggested test addition
it('works with no query no hash', () => {
- expect(parseURL('/foo')).toEqual({
+ const result = parseURL('/foo')
+ expect(result).toEqual({
fullPath: '/foo',
path: '/foo',
hash: '',
query: {},
})
+ expect(Object.getPrototypeOf(result.query)).toBe(null)
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| it('works with no query no hash', () => { | |
| expect(parseURL('/foo')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path with no query', () => { | |
| expect(parseURL('foo#hash')).toEqual({ | |
| fullPath: '/foo#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path', () => { | |
| expect(parseURL('foo?f=foo#hash')).toEqual({ | |
| fullPath: '/foo?f=foo#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with only query', () => { | |
| expect(parseURL('?f=foo')).toEqual({ | |
| fullPath: '/?f=foo', | |
| path: '/', | |
| hash: '', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with only hash', () => { | |
| expect(parseURL('#foo')).toEqual({ | |
| fullPath: '/#foo', | |
| path: '/', | |
| hash: '#foo', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path and current location', () => { | |
| expect(parseURL('foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo', | |
| path: '/parent/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path with query and hash and current location', () => { | |
| expect(parseURL('foo?f=foo#hash', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo?f=foo#hash', | |
| path: '/parent/foo', | |
| hash: '#hash', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with relative query and current location', () => { | |
| expect(parseURL('?f=foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar?f=foo', | |
| path: '/parent/bar', | |
| hash: '', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with relative hash and current location', () => { | |
| expect(parseURL('#hash', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar#hash', | |
| path: '/parent/bar', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| }) | |
| it('extracts the query', () => { | |
| expect(parseURL('/foo?a=one&b=two')).toEqual({ | |
| fullPath: '/foo?a=one&b=two', | |
| path: '/foo', | |
| hash: '', | |
| query: { | |
| a: ['one'], | |
| b: ['two'], | |
| }, | |
| }) | |
| }) | |
| it('extracts the hash', () => { | |
| expect(parseURL('/foo#bar')).toEqual({ | |
| fullPath: '/foo#bar', | |
| path: '/foo', | |
| hash: '#bar', | |
| query: {}, | |
| }) | |
| }) | |
| it('extracts query and hash', () => { | |
| expect(parseURL('/foo?a=one#bar')).toEqual({ | |
| fullPath: '/foo?a=one#bar', | |
| path: '/foo', | |
| hash: '#bar', | |
| query: { a: ['one'] }, | |
| }) | |
| }) | |
| it('extracts multiple query parameters as an array', () => { | |
| expect(parseURL('/foo?a=one&a=two&a=three')).toEqual({ | |
| fullPath: '/foo?a=one&a=two&a=three', | |
| path: '/foo', | |
| hash: '', | |
| query: { a: ['one', 'two', 'three'] }, | |
| }) | |
| }) | |
| it('correctly parses a ? after the hash', () => { | |
| expect(parseURL('/foo#?a=one')).toEqual({ | |
| fullPath: '/foo#?a=one', | |
| path: '/foo', | |
| hash: '#?a=one', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo/?a=two#?a=one')).toEqual({ | |
| fullPath: '/foo/?a=two#?a=one', | |
| path: '/foo/', | |
| hash: '#?a=one', | |
| query: { a: ['two'] }, | |
| }) | |
| }) | |
| it('works with empty query', () => { | |
| expect(parseURL('/foo?#hash')).toEqual({ | |
| fullPath: '/foo?#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo#hash')).toEqual({ | |
| fullPath: '/foo#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo?')).toEqual({ | |
| fullPath: '/foo?', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with empty hash', () => { | |
| expect(parseURL('/foo#')).toEqual({ | |
| fullPath: '/foo#', | |
| path: '/foo', | |
| hash: '#', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo?#')).toEqual({ | |
| fullPath: '/foo?#', | |
| path: '/foo', | |
| hash: '#', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with a relative paths', () => { | |
| expect(parseURL('foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo', | |
| path: '/parent/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('./foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo', | |
| path: '/parent/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('../foo', '/parent/bar')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| // cannot go below root | |
| expect(parseURL('../../foo', '/parent/bar')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar', | |
| path: '/parent/bar', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('#foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar#foo', | |
| path: '/parent/bar', | |
| hash: '#foo', | |
| query: {}, | |
| }) | |
| expect(parseURL('?o=o', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar?o=o', | |
| path: '/parent/bar', | |
| hash: '', | |
| query: { o: ['o'] }, | |
| }) | |
| }) | |
| it('works with no query no hash', () => { | |
| const result = parseURL('/foo') | |
| expect(result).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(Object.getPrototypeOf(result.query)).toBe(null) | |
| }) | |
| it('works with partial path with no query', () => { | |
| expect(parseURL('foo#hash')).toEqual({ | |
| fullPath: '/foo#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path', () => { | |
| expect(parseURL('foo?f=foo#hash')).toEqual({ | |
| fullPath: '/foo?f=foo#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with only query', () => { | |
| expect(parseURL('?f=foo')).toEqual({ | |
| fullPath: '/?f=foo', | |
| path: '/', | |
| hash: '', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with only hash', () => { | |
| expect(parseURL('#foo')).toEqual({ | |
| fullPath: '/#foo', | |
| path: '/', | |
| hash: '#foo', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path and current location', () => { | |
| expect(parseURL('foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo', | |
| path: '/parent/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with partial path with query and hash and current location', () => { | |
| expect(parseURL('foo?f=foo#hash', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo?f=foo#hash', | |
| path: '/parent/foo', | |
| hash: '#hash', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with relative query and current location', () => { | |
| expect(parseURL('?f=foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar?f=foo', | |
| path: '/parent/bar', | |
| hash: '', | |
| query: { f: ['foo'] }, | |
| }) | |
| }) | |
| it('works with relative hash and current location', () => { | |
| expect(parseURL('#hash', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar#hash', | |
| path: '/parent/bar', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| }) | |
| it('extracts the query', () => { | |
| expect(parseURL('/foo?a=one&b=two')).toEqual({ | |
| fullPath: '/foo?a=one&b=two', | |
| path: '/foo', | |
| hash: '', | |
| query: { | |
| a: ['one'], | |
| b: ['two'], | |
| }, | |
| }) | |
| }) | |
| it('extracts the hash', () => { | |
| expect(parseURL('/foo#bar')).toEqual({ | |
| fullPath: '/foo#bar', | |
| path: '/foo', | |
| hash: '#bar', | |
| query: {}, | |
| }) | |
| }) | |
| it('extracts query and hash', () => { | |
| expect(parseURL('/foo?a=one#bar')).toEqual({ | |
| fullPath: '/foo?a=one#bar', | |
| path: '/foo', | |
| hash: '#bar', | |
| query: { a: ['one'] }, | |
| }) | |
| }) | |
| it('extracts multiple query parameters as an array', () => { | |
| expect(parseURL('/foo?a=one&a=two&a=three')).toEqual({ | |
| fullPath: '/foo?a=one&a=two&a=three', | |
| path: '/foo', | |
| hash: '', | |
| query: { a: ['one', 'two', 'three'] }, | |
| }) | |
| }) | |
| it('correctly parses a ? after the hash', () => { | |
| expect(parseURL('/foo#?a=one')).toEqual({ | |
| fullPath: '/foo#?a=one', | |
| path: '/foo', | |
| hash: '#?a=one', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo/?a=two#?a=one')).toEqual({ | |
| fullPath: '/foo/?a=two#?a=one', | |
| path: '/foo/', | |
| hash: '#?a=one', | |
| query: { a: ['two'] }, | |
| }) | |
| }) | |
| it('works with empty query', () => { | |
| expect(parseURL('/foo?#hash')).toEqual({ | |
| fullPath: '/foo?#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo#hash')).toEqual({ | |
| fullPath: '/foo#hash', | |
| path: '/foo', | |
| hash: '#hash', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo?')).toEqual({ | |
| fullPath: '/foo?', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with empty hash', () => { | |
| expect(parseURL('/foo#')).toEqual({ | |
| fullPath: '/foo#', | |
| path: '/foo', | |
| hash: '#', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo?#')).toEqual({ | |
| fullPath: '/foo?#', | |
| path: '/foo', | |
| hash: '#', | |
| query: {}, | |
| }) | |
| expect(parseURL('/foo')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| }) | |
| it('works with a relative paths', () => { | |
| expect(parseURL('foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo', | |
| path: '/parent/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('./foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/foo', | |
| path: '/parent/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('../foo', '/parent/bar')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| // cannot go below root | |
| expect(parseURL('../../foo', '/parent/bar')).toEqual({ | |
| fullPath: '/foo', | |
| path: '/foo', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar', | |
| path: '/parent/bar', | |
| hash: '', | |
| query: {}, | |
| }) | |
| expect(parseURL('#foo', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar#foo', | |
| path: '/parent/bar', | |
| hash: '#foo', | |
| query: {}, | |
| }) | |
| expect(parseURL('?o=o', '/parent/bar')).toEqual({ | |
| fullPath: '/parent/bar?o=o', | |
| path: '/parent/bar', | |
| hash: '', | |
| query: { o: ['o'] }, | |
| }) | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/router/src/experimental/location.spec.ts` around lines 8 - 236, Add
explicit assertions in the location.spec tests to verify the security invariant
that parseURL returns a query object with a null prototype; locate the parseURL
expectations (e.g., in the tests like "works with no query no hash", "extracts
the query", and other cases referencing parseURL) and after each
expect(parseURL(...)).toEqual(...) add an additional assertion that
Object.getPrototypeOf(result.query) === null (or use
expect(Object.getPrototypeOf(result.query)).toBeNull()) so every relevant test
verifies the query object's prototype is null.
| import { | ||
| experimental_normalizeQuery as normalizeQuery, | ||
| experimental_parseQuery as parseQuery, | ||
| } from '../query' | ||
| import { stringifyQuery } from '../../query' | ||
| import { experimental_parseURL as parseURL } from '../location' |
There was a problem hiding this comment.
The switch to experimental normalizeQuery/parseQuery (always-array values) exposes a pre-existing type inconsistency in named-location query building.
After this import change, normalizeQuery(to.query) produces Record<string, (string | null)[]>. However, query.build(params) (line 266) returns plain string values (e.g., { page: '42' }), which Object.assign then overwrites the array values with. The resulting url.query violates LocationQuery = Record<string, (string | null)[]>, as confirmed by the explicit test assertion (resolver-fixed.spec.ts:691):
query: { page: '42', other: ['value'] }, // matcher param overrides to.query
This creates two additional problems:
- Type contract broken: consumers reading
route.querymay receive a plainstringfor matcher-derived keys despite the type promising(string | null)[]. - Inconsistent matcher input:
query.match(url.query)receives all-array values for string-location resolution but mixed (array + scalar) values for named-location resolution, making correct matcher implementations fragile.
The fix would require either making MatcherPatternQuery.build return array values, or post-processing the merged query to normalize all values to arrays after the Object.assign.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/router/src/experimental/route-resolver/resolver-fixed.ts` around
lines 1 - 6, After switching to experimental normalizeQuery/parseQuery the
merged url.query can contain scalar strings because query.build(params) returns
plain strings that Object.assign overwrites array values with; to fix, after
merging call experimental_normalizeQuery (normalizeQuery) on the final url.query
to ensure every value is an array (or alternatively change
MatcherPatternQuery.build to return array values), i.e. locate the code that
does Object.assign into url.query and either adjust MatcherPatternQuery.build to
emit (string|null)[] for keys or simply post-process the merged object with
normalizeQuery(url.query) before using url.query and before calling query.match
so the shape always matches LocationQuery.
| import { experimental_parseQuery as originalParseQuery } from './query' | ||
| import { stringifyQuery as originalStringifyQuery } from '../query' |
There was a problem hiding this comment.
parseQuery option is declared in EXPERIMENTAL_RouterOptions_Base but is not consumed — it silently has no effect.
The import at line 25 is used only to type parseQuery?: typeof originalParseQuery in the options interface (line 155). The actual destructuring of parseQuery from options (line 622) is commented out, and the resolver (resolver-fixed.ts) hard-codes experimental_parseQuery directly rather than delegating to the router option. Any user who sets parseQuery in router options will see no change in behavior.
At a minimum, emit a __DEV__ warning when the option is supplied, or remove it from the interface until it is implemented.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/router/src/experimental/router.ts` around lines 25 - 26, The EXPR
option parseQuery declared on EXPERIMENTAL_RouterOptions_Base (typed via
originalParseQuery) is not used: parseQuery is only a type (originalParseQuery)
while its destructuring from options (parseQuery) is commented out and
resolver-fixed.ts hard-codes experimental_parseQuery, so providing the option
has no effect; fix by either wiring the option through or warning—uncomment and
reintroduce the parseQuery destructuring in the router factory (so
options.parseQuery is captured), change resolver-fixed.ts to accept and call the
provided parseQuery instead of using experimental_parseQuery directly (fall back
to experimental_parseQuery when options.parseQuery is undefined), and add a
__DEV__ warning if parseQuery is present but not supported; alternatively remove
parseQuery from EXPERIMENTAL_RouterOptions_Base if you don’t intend to support
it.
83f287c to
acf3790
Compare
Changes parseQuery, normalizeQuery, and parseURL to use Object.create(null)
instead of {} when creating query objects. This prevents prototype pollution
via __proto__ and constructor keys in query strings.
Fixes vuejs#2658
Move the prototype-pollution fix out of the legacy parseQuery / normalizeQuery / parseURL (reverted to plain object literals with a TODO for the next major) and into a new experimental_parseQuery, experimental_normalizeQuery and experimental_parseURL that live in src/experimental/. All consumers under src/experimental/ are switched to the new functions via aliased imports (experimental_thing as thing) so call sites stay unchanged. Adds prototype-pollution and normalizeQuery tests for the experimental variants. Refs vuejs#2658
34d5ee9 to
4ed8c67
Compare
[skip ci]
posva
left a comment
There was a problem hiding this comment.
Thanks! Since it's a breaking change and the security impact doesn't seem high, I applied the change to the upcoming experimental router (where I can release breaking changes freely)
…2661) Co-authored-by: wdsmini <wdsmini@wdsmini.local> Co-authored-by: Eduardo San Martin Morote <posva13@gmail.com>
Summary
This PR fixes a security vulnerability where query objects created with
{}are susceptible to prototype pollution via__proto__andconstructorkeys.Changes
parseQuery(): UseObject.create(null)instead of{}normalizeQuery(): UseObject.create(null)instead of{}parseURL(): UseObject.create(null)for initial query objectSecurity Impact
Before this fix, an attacker could craft a URL like:
/?__proto__=evil&foo=bar- pollutes the query object's prototype/?constructor=test- leaks internal Object constructorAfter this fix, these keys are treated as plain data without affecting the prototype chain.
Fixes
Fixes #2658
Summary by CodeRabbit