Skip to content

Fix: getQueryArgs discards everything after the second = in a query argument value - #81066

Open
hbhalodia wants to merge 3 commits into
WordPress:trunkfrom
hbhalodia:fix/issue-81062
Open

Fix: getQueryArgs discards everything after the second = in a query argument value#81066
hbhalodia wants to merge 3 commits into
WordPress:trunkfrom
hbhalodia:fix/issue-81062

Conversation

@hbhalodia

Copy link
Copy Markdown
Contributor

What?

Closes #81062

Why?

  • PR resolve the ambiguity in parsing the URL query param in function getQueryArgs from @wordpress/url package.

How?

  • Update the function to parse the = sign correctly.

Testing Instructions

  1. Open page/post.
  2. Open console and make sure you are on top frame.
  3. In console, run wp.url.getQueryArgs( 'https://example.com/?a=1&b=x=y&c=3' ) or multiple cases mentioned in issue.

Testing Instructions for Keyboard

  • None

Screenshots or screencast

  • None

Use of AI Tools

  • Claude Code Opus 5.
  • Used for implementation draft and final code reviwed by me.

AI Summary

What?

Closes #81062

getQueryArgs() splits each query argument on the first = only. Previously it split on every = and kept just the first two segments, silently discarding everything after the second one.

Why?

packages/url/src/get-query-args.ts parsed each key=value pair with:

const [ key, value = '' ] = keyValue
	.split( '=' )
	.filter( Boolean )
	.map( safeDecodeURIComponent );

'b=x=y'.split( '=' ) yields [ 'b', 'x', 'y' ], and the destructuring reads only the first two entries — so 'y' was dropped. Any value legitimately containing an unencoded = was truncated:

Input Before After
?b=x=y { b: 'x' } { b: 'x=y' }
?token=eyJhbGciOiJIUzI1NiJ9== { token: 'eyJhbGciOiJIUzI1NiJ9' } { token: 'eyJhbGciOiJIUzI1NiJ9==' }
?redirect=/watch?v=abc { redirect: '/watch?v' } { redirect: '/watch?v=abc' }
?=orphan { orphan: '' } {}

The blast radius is wider than one function, because getQueryArgs() is the shared parser:

  • addQueryArgs() merges existing args via getQueryArgs(), so appending a parameter to a URL rewrote the truncated value back out — silent data loss rather than a read-only glitch.
  • @wordpress/api-fetch calls addQueryArgs() from its default user-locale middleware, so every request whose URL carried a = in a value was affected.
  • getQueryArg(), hasQueryArg(), and removeQueryArgs() all delegate to it too.

Base64 (== padding), JWTs, and redirect targets are exactly the values that carry =, so this hit auth tokens and redirect URLs hardest.

The last row is the secondary defect from the issue: .filter( Boolean ) dropped the empty string from [ '', 'orphan' ], leaving [ 'orphan' ], which promoted a keyless value into a key.

How?

Find the first = with indexOf and slice around it, instead of splitting on all of them:

const separatorIndex = keyValue.indexOf( '=' );
const hasValue = separatorIndex !== -1;
const key = safeDecodeURIComponent(
	hasValue ? keyValue.slice( 0, separatorIndex ) : keyValue
);

if ( key ) {
	const value = hasValue
		? safeDecodeURIComponent( keyValue.slice( separatorIndex + 1 ) )
		: '';
	const segments = key.replace( /\]/g, '' ).split( '[' );
	setPath( accumulator, segments, value );
}

Dropping .filter( Boolean ) is what fixes the orphan case: an empty key now reaches the pre-existing if ( key ) guard and the malformed pair is skipped. value moved inside that guard because it is only needed there.

File Change
packages/url/src/get-query-args.ts First-= split; keyless pairs ignored
packages/url/src/test/index.js 4 cases added to the existing getQueryArgs describe
packages/url/CHANGELOG.md Bug Fixes entry under Unreleased
Behavior deliberately preserved
  • No = at all?foo still gives { foo: '' }. hasValue is false, so the empty-string default applies exactly as the old destructuring default did.
  • Trailing =?foo= still gives { foo: '' }, now because the slice is empty rather than because the filter removed it.
  • Empty segments?&foo and ? still skip the blanks, covered by the existing "should gracefully handle empty keys and values" test.
  • Bracket syntaxfoo[]=, foo[0]=, user[name]=, and the percent-encoded user%5Bname%5D= forms are untouched. The key is still decoded before being split on [, which is what makes the encoded variants work.
  • Malformed escapes — both halves still go through safeDecodeURIComponent, so ?baz=%E0%A4%A returns the raw string instead of throwing.
  • Round tripbuildQueryString() encodes = in values as %3D, so addQueryArgs() output stays correctly encoded, and the two "reverses buildQueryString" tests are unaffected.

No API surface, signature, or type changed — QueryArgs and the exported signature are identical.

Testing Instructions

Unit tests:

npm run test:unit -- packages/url
Manual verification in the browser console

On any wp-admin screen (@wordpress/url is exposed as wp.url):

wp.url.getQueryArgs( '?b=x=y' );
// { b: 'x=y' }

wp.url.getQueryArgs( '?token=eyJhbGciOiJIUzI1NiJ9==' );
// { token: 'eyJhbGciOiJIUzI1NiJ9==' }

wp.url.getQueryArgs( 'https://example.com/?redirect=/watch?v=abc' );
// { redirect: '/watch?v=abc' }

wp.url.getQueryArgs( '?=orphan&foo=bar' );
// { foo: 'bar' }

// The regression that mattered — no longer rewrites a truncated value:
wp.url.addQueryArgs( 'https://example.com/?token=abc==', { x: 1 } );
// 'https://example.com/?token=abc%3D%3D&x=1'    (before: '...?token=abc&x=1')

// Unchanged behavior:
wp.url.getQueryArgs( '?foo' );                       // { foo: '' }
wp.url.getQueryArgs( '?&foo' );                      // { foo: '' }
wp.url.getQueryArgs( '?user%5Bname%5D=Bob%20Smith' ); // { user: { name: 'Bob Smith' } }
wp.url.getQueryArgs( '?foo[]=a&foo[]=b' );           // { foo: [ 'a', 'b' ] }
wp.url.getQueryArgs( '?baz=%E0%A4%A' );              // { baz: '%E0%A4%A' } — does not throw

Every line above except the addQueryArgs one can also be checked against trunk to see the truncation.

@hbhalodia hbhalodia self-assigned this Aug 3, 2026
@github-actions github-actions Bot added the [Package] Url /packages/url label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @konnen916.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: konnen916.

Co-authored-by: hbhalodia <hbhalodia@git.wordpress.org>
Co-authored-by: aduth <aduth@git.wordpress.org>
Co-authored-by: ramonjd <ramonopoly@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@konnen916

Copy link
Copy Markdown

The fix reads correctly to me. I checked out the branch and ran the package suite against it.

One gap worth filling: there is no test for the round trip through addQueryArgs. That is the path that makes this reachable in practice, since the default user-locale middleware in api-fetch calls addQueryArgs on any request that does not already carry _locale, so an affected value gets truncated on its way out rather than only when something reads it back.

Both of these already pass on your branch, so this is a regression guard rather than anything wrong with the change:

it( 'should not truncate an existing value containing equals signs', () => {
	const url =
		'https://andalouses.example/beach?cursor=eyJvZmZzZXQiOjIwfQ==';
	const args = { sun: 'true' };

	expect( addQueryArgs( url, args ) ).toBe(
		'https://andalouses.example/beach?cursor=eyJvZmZzZXQiOjIwfQ%3D%3D&sun=true'
	);
} );

it( 'should not truncate a nested URL passed as an existing value', () => {
	const url = 'https://andalouses.example/beach?redirect=/watch?v=abc';
	const args = { sun: 'true' };

	expect( addQueryArgs( url, args ) ).toBe(
		'https://andalouses.example/beach?redirect=%2Fwatch%3Fv%3Dabc&sun=true'
	);
} );

The four new tests all sit in the getQueryArgs block, so a later change there could bring the truncation back at the addQueryArgs level with everything still green.

Minor one too: the changelog entry links to /issues/81066, where the other entries in that file use /pull/.

@ramonjd
ramonjd requested review from Mamaduka and aduth August 4, 2026 01:45
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: [Package] Url.

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@aduth

aduth commented Aug 4, 2026

Copy link
Copy Markdown
Member

I wish String#split's limit argument behaved like other languages where the remainder after the limit is left intact (e.g. in Ruby, 'a=b=c'.split('=',2) results in ["a", "b=c"]). That would make this a pretty easy fix. But JavaScript's behavior does the full split and discards everything outside the limit, which doesn't help us here (in JavaScript, 'a=b=c'.split('=',2) results in [ 'a', 'b' ]).

I still wonder if we could keep the split and assign the value parts as a spread like const [ key, ...valueParts ] = keyValue.split( '=' ); and rejoin the parts.

Something like this isn't the prettiest code, but it's close to what we have:

let [ key, ...valueParts ] = keyValue.split( '=' );
let value = valueParts.join( '=' );
key = key && safeDecodeURIComponent( key );
value = value && safeDecodeURIComponent( value );

Although I think the proposed implementation has some advantages in terms of doing the least work necessary to manipulate and decode the parts, which seems nice 👍

I'd be curious about your feedback to @konnen916 's comment, but otherwise this looks good to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Url /packages/url

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getQueryArgs discards everything after the second = in a query argument value

3 participants