Skip to content

fix(query): use Object.create(null) to prevent prototype pollution#2661

Merged
posva merged 6 commits into
vuejs:mainfrom
wdskuki:fix/parseQuery-prototype-pollution
May 6, 2026
Merged

fix(query): use Object.create(null) to prevent prototype pollution#2661
posva merged 6 commits into
vuejs:mainfrom
wdskuki:fix/parseQuery-prototype-pollution

Conversation

@wdskuki

@wdskuki wdskuki commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes a security vulnerability where query objects created with {} are susceptible to prototype pollution via __proto__ and constructor keys.

Changes

  • parseQuery(): Use Object.create(null) instead of {}
  • normalizeQuery(): Use Object.create(null) instead of {}
  • parseURL(): Use Object.create(null) for initial query object

Security 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 constructor

After this fix, these keys are treated as plain data without affecting the prototype chain.

Fixes

Fixes #2658

Summary by CodeRabbit

  • New Features
    • Experimental query parsing and normalization added for more consistent query handling and URL normalization.
  • Bug Fixes
    • Improved handling of repeated params, decoding edge cases, relative path/query/hash resolution, and safety against prototype-pollution-like issues.
  • Tests
    • Expanded test coverage for query parsing, normalization, URL parsing, and resolver behavior.
  • Refactor
    • Internal parsing wiring updated to use the new experimental implementations.

@netlify

netlify Bot commented Mar 16, 2026

Copy link
Copy Markdown

Deploy Preview for vue-router canceled.

Name Link
🔨 Latest commit 6c74b7f
🔍 Latest deploy log https://app.netlify.com/projects/vue-router/deploys/69fb33558334bd0008ebf0cf

@coderabbitai

coderabbitai Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b6d44d62-fc28-42fb-8ea0-fda362d69f88

📥 Commits

Reviewing files that changed from the base of the PR and between 4ed8c67 and 6c74b7f.

📒 Files selected for processing (1)
  • packages/router/src/experimental/router.ts

📝 Walkthrough

Walkthrough

Adds 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 parseURL about switching query objects to Object.create(null).

Changes

Experimental Query & URL parsing

Layer / File(s) Summary
Type definitions
packages/router/src/experimental/query.ts
Adds LocationQueryValue, LocationQueryValueRaw, LocationQuery, and LocationQueryRaw.
Core Query parsing / normalization
packages/router/src/experimental/query.ts
Adds experimental_parseQuery(search: string): LocationQuery and `experimental_normalizeQuery(query: LocationQueryRaw
URL parsing implementation
packages/router/src/experimental/location.ts
Adds experimental_parseURL(parseQuery, location, currentLocation?) to split path/search/hash, ignore ? in hash, call provided parser, decode hash, resolve relative paths, and assemble LocationNormalized.
Integration / wiring
packages/router/src/experimental/route-resolver/resolver-fixed.ts, packages/router/src/experimental/router.ts
Switches experimental resolver/router wiring to import and use experimental_parseQuery, experimental_normalizeQuery, and experimental_parseURL (updated import paths). Updates EXPERIMENTAL_RouterOptions_Base.parseQuery to refer to the experimental parseQuery type.
Import path updates
packages/router/src/experimental/data-loaders/defineColadaLoader.ts, packages/router/src/experimental/data-loaders/utils.ts, packages/router/src/experimental/route-resolver/resolver-abstract.ts
Adjusts LocationQuery/LocationQueryRaw import paths from ../../query to ../query.
Tests
packages/router/src/experimental/query.spec.ts, packages/router/src/experimental/location.spec.ts
Adds tests covering experimental_parseQuery, experimental_normalizeQuery, and experimental_parseURL (decoding, + handling, repeated keys, null/undefined normalization, prototype-safety, hash/query edge cases, delegation).
Stable module notes / formatting
packages/router/src/location.ts, packages/router/src/query.ts
Adds a TODO in stable parseURL about switching to Object.create(null) later; minor JSDoc/formatting adjustment in query.ts.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • vuejs/router#2661: Touches query parsing/URL parsing and implements the Object.create(null) prototype-pollution fix referenced by TODOs here.
  • vuejs/router#2415: Related changes to parser wiring and experimental parseURL/parseQuery imports.

Suggested reviewers

  • posva

Poem

🐇
I hopped through queries, small and neat,
Turned "%20" to spaces with nimble feet.
No proto tricks shall cloud my way,
I parse, I normalize — hip hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive Most changes directly address the prototype pollution fix. However, import path updates (e.g., ../../query to ../query) appear to be refactoring unrelated to the security fix. Clarify whether the import path changes from ../../query to ../query in experimental modules are intentional refactoring or accidental scope creep from the prototype pollution fix.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main security fix: preventing prototype pollution by using Object.create(null) in query parsing.
Linked Issues check ✅ Passed The PR implements all three required changes (parseQuery, normalizeQuery, parseURL) using Object.create(null) to prevent prototype pollution as specified in issue #2658.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@posva posva added the 💥 breaking change introduces a breaking change label Mar 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

The inc-query-hash redirect at line 266 relies on implicit array-to-string coercion that would silently misbehave for multi-valued params.

to.query.n is now (string | null)[], so to.query.n + '-2' coerces the array to a string: ['3'] + '-2' = '3-2'. This produces the expected ['3-2'] after normalization. However, if n appeared 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.match diverges from its intended null behavior when query.count is [null].

With LocationQuery = Record<string, (string | null)[]>, query.count is now (string | null)[] | undefined. The check if (!query.count) handles undefined correctly but treats [null] (produced by ?count with no value) as truthy — a non-empty array is always truthy. Number([null]) evaluates to 0 (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 experimental LocationQuery always 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 win

Add an own-property guard to experimental_normalizeQuery to match the PR's hardening intent.

for...in iterates inherited enumerable properties. If Object.prototype has 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3020739 and 83f287c.

📒 Files selected for processing (13)
  • packages/router/src/experimental/data-loaders/defineColadaLoader.ts
  • packages/router/src/experimental/data-loaders/utils.ts
  • packages/router/src/experimental/location.spec.ts
  • packages/router/src/experimental/location.ts
  • packages/router/src/experimental/query.spec.ts
  • packages/router/src/experimental/query.ts
  • packages/router/src/experimental/route-resolver/resolver-abstract.ts
  • packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts
  • packages/router/src/experimental/route-resolver/resolver-fixed.ts
  • packages/router/src/experimental/router.spec.ts
  • packages/router/src/experimental/router.ts
  • packages/router/src/location.ts
  • packages/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

Comment thread packages/router/src/experimental/data-loaders/utils.ts Outdated
Comment on lines +8 to +236
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'] },
})
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
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.

Comment thread packages/router/src/experimental/query.spec.ts
Comment on lines +1 to +6
import {
experimental_normalizeQuery as normalizeQuery,
experimental_parseQuery as parseQuery,
} from '../query'
import { stringifyQuery } from '../../query'
import { experimental_parseURL as parseURL } from '../location'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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:

  1. Type contract broken: consumers reading route.query may receive a plain string for matcher-derived keys despite the type promising (string | null)[].
  2. 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.

Comment on lines +25 to +26
import { experimental_parseQuery as originalParseQuery } from './query'
import { stringifyQuery as originalStringifyQuery } from '../query'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

@posva posva force-pushed the fix/parseQuery-prototype-pollution branch from 83f287c to acf3790 Compare May 6, 2026 12:14
wdsmini and others added 5 commits May 6, 2026 14:19
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
@posva posva force-pushed the fix/parseQuery-prototype-pollution branch from 34d5ee9 to 4ed8c67 Compare May 6, 2026 12:20
[skip ci]

@posva posva left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@posva posva merged commit 8b2f422 into vuejs:main May 6, 2026
4 checks passed
posva added a commit that referenced this pull request May 6, 2026
…2661)

Co-authored-by: wdsmini <wdsmini@wdsmini.local>
Co-authored-by: Eduardo San Martin Morote <posva13@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💥 breaking change introduces a breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parseQuery creates objects with {} instead of Object.create(null), allowing __proto__ key corruption

2 participants