Sitelet https://github.com/unjs/ufo/pull/355
Skip to content

fix(parseQuery): keep the value of an empty-key parameter - #355

Open
spokodev wants to merge 1 commit into
unjs:mainfrom
spokodev:fix/parsequery-empty-key
Open

spokodev wants to merge 1 commit into
unjs:mainfrom
spokodev:fix/parsequery-empty-key

Conversation

@spokodev

@spokodev spokodev commented Jun 23, 2026 •

Copy link
Copy Markdown

Problem

parseQuery matches each parameter with /([^=]+)=?(.*)/. The key sub-pattern [^=]+ requires at least one non-= character, so for an empty-key parameter the match anchors past the =:

  • =b parses as { b: "" }: the real value "b" is silently lost and a spurious key "b" is invented.
  • == and a bare = are dropped entirely.

This corrupts parseQuery, getQuery, filterQuery, and normalizeURL for any URL carrying an empty-key parameter.

WHATWG URLSearchParams keeps the empty key:

[...new URLSearchParams("=b")]      // [["", "b"]]
[...new URLSearchParams("a=1&=b")]  // [["a", "1"], ["", "b"]]
[...new URLSearchParams("==")]      // [["", "="]]
[...new URLSearchParams("")]        // []

Fix

Change the key pattern [^=]+ to [^=]* so an empty key matches, and add an explicit skip of fully-empty parts (if (!parameter) continue;) to preserve the current behavior of dropping "" and && (with the star pattern the match no longer returns a length-0 array, so the skip has to be explicit).

After the fix, parseQuery matches URLSearchParams across the matrix:

input before after / URLSearchParams
"" {} {}
=b { b: "" } { "": "b" }
a=1&=b { a: "1", b: "" } { a: "1", "": "b" }
== {} { "": "=" }
& {} {}
a { a: "" } { a: "" }
a= { a: "" } { a: "" }
&& {} {}
=b&=c { b: "", c: "" } { "": ["b", "c"] }

Tests

Added four empty-key cases to test/query.test.ts. They fail on the current code (red) and pass with the fix (green); the full suite stays green.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced query parameter parsing to correctly handle edge cases where query strings contain empty keys.
  • Tests

    • Expanded test suite to verify proper handling of query parameters with empty keys in various URL formats.

The per-parameter key pattern [^=]+ requires a non-empty key, so "=b"
parsed as { b: "" } losing the value "b", and "==" / "=" were dropped.
WHATWG URLSearchParams keeps the empty key: "=b" -> { "": "b" }.

Switch the key pattern to [^=]* and skip fully-empty parts explicitly,
which matches URLSearchParams across the matrix ("", "=b", "a=1&=b",
"==", "&", "a", "a=", "&&", "=b&=c").
@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d8a46a2f-fe20-4e24-875a-a08c9ecbb8fd

📥 Commits

Reviewing files that changed from the base of the PR and between f06c800 and 44c315b.

📒 Files selected for processing (2)
  • src/query.ts
  • test/query.test.ts

📝 Walkthrough

Walkthrough

parseQuery in src/query.ts gains an early-continue guard for empty &-split segments and relaxes the key-matching regex from ([^=]+) to ([^=]*), allowing empty-string keys. A new test suite in test/query.test.ts verifies getQuery behavior for URLs with empty query parameter keys.

Changes

Empty key parsing in parseQuery

Layer / File(s) Summary
Empty key guard, regex fix, and tests
src/query.ts, test/query.test.ts
parseQuery adds a continue when a split segment is empty and widens the key regex to ([^=]*). New describe("parseQuery empty key") block tests ?=b, ?a=1&=b, ?==, and ?=b&=c, asserting the empty-string key maps to the expected value or array.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~3 minutes

Poem

🐇 A key with no name, just an = in the air,
The query string shrugged and said, "Put something there!"
With a * in the regex and a guard on the loop,
Empty strings now land safely inside the data soup.
Hop hop, the parser is happy — no edge case left bare! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main fix: handling empty-key query parameters in parseQuery, which matches the primary objective and code changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

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.

silverfish2525 added a commit to silverfish2525/better-ufo that referenced this pull request Jul 1, 2026
… + empty-key fixes

Adopts three upstream unjs/ufo PRs into the fork:

- PR unjs#331 (@saripovdenis) \u2014 parseQuery charCode-scan rewrite, ~40% faster
  on real-world benchmarks (empty +421%, single pair +72%, mixed +36%,
  repeated keys +43%, encoded +32%, leading-equals +37%, long +22%).
- PR unjs#333 (@saripovdenis) \u2014 stringifyQuery single-pass string build.
- PR unjs#289 (@pi0) \u2014 prototype pollution guard for the parsed object.

Also fixes issue unjs#355 (empty-key parameter loss). Previous regex
`/([^=]+)=?(.*)/` required a non-empty key so `=b` was parsed as
`{b: ''}` (silently inventing a key). Now `=b` \u2192 `{'': 'b'}`, matching
native `URLSearchParams` iteration output.

Prototype-pollution guard tightened: previously blocked `__proto__` and
`constructor`; now also blocks `prototype` (aligned with owasp guidance).

Regression tests added:
- Empty-key preservation (`=b`, `a=1&=b`, `==`)
- URLSearchParams parity for values containing `=`
- null-prototype return object
- Dangerous-key filter including `prototype`

Tests: 995 pass + 65 xfail (was 991 + 65).
silverfish2525 added a commit to silverfish2525/better-ufo that referenced this pull request Jul 1, 2026
CREDITS.md itemizes every upstream unjs/ufo PR and issue whose work
was adopted by this fork, grouped by category:

- Perf: @saripovdenis (unjs#331, unjs#333)
- Security: @pi0 (unjs#289), @spokodev (unjs#355)
- Encoding: converged 11 PRs from 10 authors into one WHATWG-aligned
  implementation \u2014 @JvanderHeide, @yyz945947732, @byt3m4st3r,
  @nianqingrenganmane, @ConnorBerghoffer, @terminalchai (x2),
  @guoyangzhen, @mvtandas, @armorbreak001, @LeSingh1
- Bug fixes: @sandros94 (unjs#237 approach)
- Features: @Thy3634 (unjs#243 proposal)

Also documents deliberately-rejected PR unjs#350 (@LeSingh1, protocol-
relative in withBase) and the v2-deferred surface (unjs#208 native URL,
unjs#189 per-protocol trailing slash, unjs#296 bracket->array parseQuery).

README top gets a fork banner linking CREDITS.md and enumerating
what this fork adds on top of upstream. Full name change to
better-ufo lands in the publish-prep commit at end of series.
silverfish2525 added a commit to silverfish2525/better-ufo that referenced this pull request Jul 1, 2026
…paque schemes)

Encoding — WHATWG application/x-www-form-urlencoded compliance
- encodeQueryValue/encodeQueryKey: byte-for-byte parity with
  URLSearchParams.toString(); |, `, ^, @, :, ,, ;, =, ? all encoded
  (adopts consensus from 11 open upstream PRs: unjs#279 unjs#288 unjs#303 unjs#305
  unjs#310 unjs#318 unjs#324 unjs#327 unjs#328 unjs#329 unjs#354)
- Single-pass replacer; %20 → + rewrite after encodeURI, before map

parseQuery — correctness + security
- Single-pass charCode scanner (PR unjs#331 @saripovdenis, ~40% faster)
- Empty-key preservation: =value → { "": "value" } (PR unjs#355 @spokodev;
  was silently losing the value with the old /([^=]+)=?(.*)/ regex)
- Proto-pollution guard: __proto__ / constructor / prototype blocked
  (PR unjs#289 @pi0; extended to cover prototype as well)
- stringifyQuery: single-pass builder (PR unjs#333 @saripovdenis)

parseURL — opaque-scheme URIs (RFC 3986 §3)
- mailto:, tel:, urn:, sms: — scheme NOT followed by // → opaque path
  surfaced in .pathname; query/fragment parsed from the opaque tail
- data:, blob: round-trip correctly (tested against WPT urltestdata.json)

parseAuth — RFC 3986 §3.2.1
- Split on FIRST colon only; subsequent colons belong to the password
  (was splitting on all colons, losing interior password chars)

parseHost — IPv6
- Brackets retained on hostname to match WHATWG URL.hostname contract
- Malformed unclosed bracket returns input verbatim (no silent truncation)
- Non-numeric port suffix detected (was silently truncated)

cleanDoubleSlashes — query/fragment protection
- Double-slash collapse no longer touches query or fragment sections

hasProtocol — single-char scheme rejection
- Blocks Windows drive letters (C:) and bare-digit prefixes

withBase / withoutBase — fragment-boundary fix
- Fragment on input (#hash) no longer defeats 'base already present'
  check (was producing doubled base path on fragment-suffixed inputs)

withFragment — empty hash strips existing fragment

WPT urltestdata.json ratchet
- 100-case special-scheme subset; 65 known-divergent cases run via
  it.fails (same count as upstream); ratchet trips if a fix lands
silverfish2525 added a commit to silverfish2525/better-ufo that referenced this pull request Jul 1, 2026
package.json
- name: ufo → better-ufo; version: 1.6.4-fork.0
- description, keywords (17), homepage, bugs, repository → fork URLs
- author: silverfish2525; contributors: upstream credits pointer
- publishConfig: access public + provenance true
- files: dist + CREDITS.md + SECURITY.md
- packageManager: pnpm@10.33.2 (exact, required by corepack)

README.md
- Title, install commands, import specifiers → better-ufo
- Fork banner: what we add (WHATWG compliance, security guards,
  literal-preserving TS inference, new APIs, CI gates)
- Drop-in pnpm overrides recipe for existing ufo consumers
- Badge shields point to npmjs.com/package/better-ufo
- License section updated; upstream attribution preserved

CREDITS.md
- Only lists contributors whose work is in the codebase (every entry
  has a line-level cite in src/); removed rejected PR unjs#350 and v2-
  deferred issues that were not adopted
- Sections: perf (unjs#331 unjs#333), security (unjs#289 unjs#355), encoding (11 PRs),
  bug fixes (unjs#237), features (unjs#243)

SECURITY.md
- GitHub private advisory as primary channel (silverfish2525/ufo)
- In-scope / out-of-scope tables; supply-chain note; zero-runtime-dep
  note; vendored punycode identified as only third-party byte

AGENTS.md + CONTRIBUTING.md
- Updated for current repo shape: tests/ (not test/), tsdown, .ts
  scripts, @antfu/eslint-config, upstream-reference convention

advisor-plans/
- 15 plan files (001–014, 021) documenting the improvement strategy
  that produced the security/correctness/type/infra changes above
- advisor-plans/README.md index
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant