angular: HttpParameterCodec improperly encodes special characters like '+' and '='

I’m submitting a …

[x ] bug report => search github for a similar issue or PR before submitting
[ ] feature request
[ ] support request => Please do not submit support request here, instead see https://github.com/angular/angular/blob/master/CONTRIBUTING.md#question

Current behavior Code exemple

let body = new URLSearchParams();

for (let i in values) {
    body.set(i, values[i]);
}

let options = RequestOptions();
options.headers.append('Content-Type', 'application/x-www-form-urlencoded')
return this.http.post(uri, body, options).then();
[...]

If values[i] has a ‘+’ character in it, then it is replaced by spaces.

Expected/desired behavior ‘+’ should be sent as any other character.

Please tell us about your environment:

  • Angular version: 2.0.0-rc.5
  • Browser: [all | Chrome XX | Firefox XX | IE XX ]
  • Language: [Typescript]

About this issue

  • Original URL
  • State: open
  • Created 8 years ago
  • Reactions: 121
  • Comments: 109 (25 by maintainers)

Commits related to this issue

Most upvoted comments

@jsgoupil actually, it’s same approach as the one shared right here.

Full usage is just below.

/*
 * CustomQueryEncoderHelper
 * Fix plus sign (+) not encoding, so sent as blank space
 * See: https://github.com/angular/angular/issues/11058#issuecomment-247367318
 */
import { QueryEncoder } from "@angular/http";

export class CustomQueryEncoderHelper extends QueryEncoder {
    encodeKey(k: string): string {
        k = super.encodeKey(k);
        return k.replace(/\+/gi, '%2B');
    }
    encodeValue(v: string): string {
        v = super.encodeValue(v);
        return v.replace(/\+/gi, '%2B');
    }
}
import { CustomQueryEncoderHelper } from '../shared/helpers';
//...
login(username: string, password: string) {
	let headers = new Headers({
		'Accept': 'application/json',
		'Content-Type': 'application/x-www-form-urlencoded'
	});

	let params: URLSearchParams = new URLSearchParams('', new CustomQueryEncoderHelper());
	params.append('username', username);
	params.append('password', password);

	return this.http.post(this.loginUrl, params.toString(), { headers: headers }).map((response: Response) => {
		//...
	});
}
//...

Guys, 5 years. Are you kidding?

almost 5 years, really? 😦

Standard behaviour can be easily overriden globally with this interceptor.

@Injectable()
export class EncodeUrlParamsSafelyInterceptor implements HttpInterceptor, HttpParameterCodec {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const newParams = new HttpParams({
      fromString: req.params.toString(),
      encoder: this,
    });

    return next.handle(req.clone({
      params: newParams,
    }));
  }

  encodeKey(key: string): string {
    return encodeURIComponent(key);
  }

  encodeValue(value: string): string {
    return encodeURIComponent(value);
  }

  decodeKey(key: string): string {
    return decodeURIComponent(key);
  }

  decodeValue(value: string): string {
    return decodeURIComponent(value);
  }
}

Then in app.module.ts

@NgModule({
    providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: EncodeUrlParamsSafelyInterceptor,
      multi: true,
    }],
})
export class AppModule {
}

Confirmed as reported. A + should definitely not be encoded as itself, as this has a different meaning in URL encoded values.

I believe the long-ago origin of this bug was the re-implementation of the incorrect logic from angular.js’ encodeUriSegment. This function was meant for paths in the URL part of the URI, not query parameters. This bug was unintentionally later copied to HttpClient, which used the same implementation of query encoding for backwards compatibility with @angular/http.

Angular’s behavior here is incorrect and should be fixed. I’m concerned that fixing it might cause issues/breaking changes if people have applied workarounds, especially in their backends. Here’s what I propose:

  1. We introduce a new HttpParameterCodec with the correct encoding behavior and deprecate the old one. It’s already possible to choose an encoder when creating HttpParams.

  2. in v10.x, we make the default encoder configurable via DI/HttpClientModule options, allowing someone to opt-in globally to the fix.

  3. in v11, we swap the default, and make an opt-out possible instead.

  4. in v12, then, we remove the opt-out and the broken codec.

How to deal with it in Angular 5.X First of all create custom encoder:

export class CustomQueryEncoderHelper implements HttpParameterCodec {
    encodeKey(k: string): string {
        return encodeURIComponent(k);
    }

    encodeValue(v: string): string {
        return encodeURIComponent(v);
    }

    decodeKey(k: string): string {
        return decodeURIComponent(k);
    }

    decodeValue(v: string): string {
        return decodeURIComponent(v);
    }
}

And then use this technic for setting query params

let preparedParams = new HttpParams({
    encoder: new CustomQueryEncoderHelper(),
    fromObject: [YOUR_PARAMS]
});

this.http.get(path, {
    params: preparedParams
});

This is still an issue in angular 4.

For angular 5.1 create your own encoder:

import {HttpParameterCodec} from '@angular/common/http'

/**
 * A `HttpParameterCodec` that uses `encodeURIComponent` and `decodeURIComponent` to
 * serialize and parse URL parameter keys and values.
 *
 * @stable
 */
export class WebHttpUrlEncodingCodec implements HttpParameterCodec {
  encodeKey(k: string): string { return encodeURIComponent(k); }

  encodeValue(v: string): string { return encodeURIComponent(v); }

  decodeKey(k: string): string { return decodeURIComponent(k); }

  decodeValue(v: string) { return decodeURIComponent(v); }
}

Then in HttpParams add it as an option!

const params = new HttpParams({encoder: new WebHttpUrlEncodingCodec() })
.set('username', this.anonUsername)
.set('password', this.anonPassword);

How is this simple of an issue not resolved yet after 1.5 years?

Ignoring the encoding for the + symbol was implemented in this commit but this creates another problem as + becomes space in some PHP implementations.

My use case is: I’m using an API that requires an application/x-www-form-urlencoded format for the payload. I want to send a string containing the character +, but it doesn’t get encoded by URLSearchParams So i try to encode it myself (it becomes %2B ) and when i set it as URLSearchParams it gets double-encoded because the % gets encoded as well.

The solution in this case is to redefine the QueryEncoder used by your implementation of URLSearchParams

// [...] Actual implementation in your class 
let params: URLSearchParams = new URLSearchParams('', new GhQueryEncoder());
// [...]

class GhQueryEncoder extends QueryEncoder {
    encodeKey(k: string): string {
        k = super.encodeKey(k);
        return k.replace(/\+/gi, '%2B');
    }
    encodeValue(v: string): string {
        v = super.encodeKey(v);
        return v.replace(/\+/gi, '%2B');
    }
}

More than 2 years and still open…

@NKjoep Just search for how to URLSearchParams with http.get. There are many examples on stack exchange. Just pass the new CustomQueryEncoderHelper() into the constructor as shown above, and otherwise use like normal.

I just ran into this rather obvious bug. Ten months without a one-liner fix is just sad.

This is still happening in Angular 4.0.1. mohammadi+1 is left as it is, so is sent to the server as mohammadi 1 (space). It should be mohammadi%2B1 to be sent to the server as mohammadi+1.

Temporarily fixed this by adding a custom QueryEncoder (like GhQueryEncoder) that extends the parent QueryEncoder.

IMO this should be done in the default QueryEncoder.

For reference, the relevant RFC is https://www.rfc-editor.org/rfc/rfc1866#section-8.2 RFC3986 defines the allowed chars in an URL. So + is allowed, as well as %20. rfc1866 defines the meaning of the characters for example in a query string. So it states clearly that space is encoded as ‘+’ - which implies that ‘+’ must be encoded otherwise.

I’m quite puzzled that this is still open and there’s no correct implementation of this after 7 years.

the issue is open 4 years ago. shitting Angular

For V3/4 I reckon we should drop this type, and delegate to the native implementation, and require it to be polyfilled otherwise. This is currently akin to importing Promise and that’s something we want to avoid.

Polyfill - https://github.com/WebReflection/url-search-params

Here’s how I solved encoding a dynamic set of params:

Create a custom encoder:

export class CustomQueryEncoderHelper implements HttpParameterCodec {
    encodeKey(k: string): string {
        return encodeURIComponent(k);
    }

    encodeValue(v: string): string {
        return encodeURIComponent(v);
    }

    decodeKey(k: string): string {
        return decodeURIComponent(k);
    }

    decodeValue(v: string): string {
        return decodeURIComponent(v);
    }
}

Create a helper function to convert params:

toHttpParams(params) {
        return Object.getOwnPropertyNames(params)
            .reduce((p, key) => p.set(key, params[key]), new HttpParams({encoder: new CustomQueryEncoderHelper()}));
    }

Usage:

get<T>(url: string, params?: {}): Observable<T> {
        let convertedParams;
        if (params) {
            convertedParams = this.toHttpParams(params);
        }

        return this.http.get(url, { params: convertedParams }).pipe(
            map((response: T) => {
                return response;
            }),
            catchError((error: Response) => {
                return throwError(error);
            })
        );
    }

Issue present in Angular 5

Error still here in Angular 4.4.4 . Hope you’ll fix that in Angular 5 !

I’d really like to have correct behavior by default, and a possible option to switch to legacy incorrect encoder just for really old projects where it’s really required because of server-side implementation based on incorrectly encoded url values… This can easily be described in upgrade instructions. “Do you rely on legacy broken url encoder? Are you in doubt? Enable this fallback option. Do you have custom params encoder which fixes old broken default one? It can be removed.”

We use alternative encoder in all our projects as a workaround already, for years, and it’s an extra code you must not forget to add to each new project, it is really bad thing.

This issue also bit me yesterday. TL;DR, I believe that the default param encoding provided by HttpClient should encode key-value pairs using encocdeURIComponent and nothing else so that data is sent to the server in most unambiguous manner possible.

It seems to me, that if a user issues an HttpClient GET request such as this.http.get(path, { params: data })..., where data is some object of type { [k: string]: string }, then the key-value pairs should be sent such that the server will receive identical key-value pairs. It’s perfectly valid to have an object or mapping with keys containing any of the URI reserved characters (RFC 3986 sec 2.2). Keys can be nearly any string. This applies to JavaScript objects, PHP arrays, and Python dictionaries. Why anyone would want to use the key “abc+def=foobar” isn’t ours to ask. But if that’s the key, the server should see that key. Same goes for values.

The current default parameter encoder, HttpUrlEncodingCodec, isn’t implemented to be unambiguous. I don’t know the history, but I’m guessing it was to make the query string “prettier”. But it can lead to unexpected behavior, as we can plainly see from this 6-year issue.

In my experimentation, I found that both PHP and Python’s Flask interpret “+” as a space in the query string. What’s more, a “=” is perfectly legal as part of the key (if unorthodox) if encoded. If multiple “=” characters appear in a query string key-value pair, then the first “=” is treated as the key-value separator, and the remaining “=” are assumed to be part of the value.

To prove out the problem, I created a simple testbed with a readily available PHP server (version 5.3.3) and Flask server (Python 3.10.4 / Flask 2.1.1 / Werkzeug 2.0.1). They are included here for completeness:

test-bed.component.ts

import { Component } from '@angular/core';
import { HttpClient, HttpParameterCodec, HttpParams } from '@angular/common/http';
import { Params } from '@angular/router';
import { fromPairs } from 'lodash-es';


@Component({
  selector: 'app-test-bed',
  templateUrl: './test-bed.component.html',
})
export class TestBedComponent {

  characters = ['@', ':', '$', ',', ';', '+', '=', '?', '/', ' '] // space is included for demonstration...

  // Obviously, these aren't the real paths ;)
  phpPath = 'http://server:8080/test.php';
  flaskPath = 'http://server:7070/test';

  useDefaultEncoder = true;

  expectedData: Params = null;
  phpData = null;
  flaskData = null;

  constructor (protected http: HttpClient) {
    this.expectedData = fromPairs(this.characters.map(x => [`a${x}b`, `c${x}d`]));
  }

  toggleEncoder (): void {
    this.useDefaultEncoder = !this.useDefaultEncoder;
  }

  testPHP (): void {
    const params = this.useDefaultEncoder ? this.expectedData : this.makeParams(this.expectedData);
    this.http.get(this.phpPath, { params: params })
      .subscribe(data => {
        this.phpData = data;
      });
  }

  testFlask (): void {
    const params = this.useDefaultEncoder ? this.expectedData : this.makeParams(this.expectedData);
    this.http.get(this.flaskPath, { params: params })
      .subscribe(data => {
        this.flaskData = data;
      });
  }

  makeParams (params: Params): HttpParams {
    return new HttpParams({ fromObject: params, encoder: new URIComponentCodec() });
  }

}

export class URIComponentCodec implements HttpParameterCodec {
  encodeKey (key: string): string {
    return encodeURIComponent(key);
  }
  encodeValue (value: string): string {
    return encodeURIComponent(value);
  }
  decodeKey (key: string): string {
    return decodeURIComponent(key);
  }
  decodeValue (value: string): string {
    return decodeURIComponent(value);
  }
}

test-bed.component.html

<button type="button" (click)="toggleEncoder()">{{ useDefaultEncoder ? 'Default Encoder' : 'Custom Encoder' }}</button>

<pre>{{ expectedData | json }}</pre>

<button type="button" (click)="testPHP()">Test PHP</button>
<pre>{{ phpData | json }}</pre>

<button type="button" (click)="testFlask()">Test Flask</button>
<pre>{{ flaskData | json }}</pre>

test.php

<?php

header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);

if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    header('Access-Control-Allow-Headers: Authorization, Content-Type');
    header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS');
    die();
}

echo json_encode($_GET);

test.py

from flask import Blueprint, jsonify, request

bp = Blueprint('base', __name__, url_prefix='')

@bp.route('/testbed')
def testbed ():
    return jsonify(request.args)

@bp.after_request
def set_cors_headers (response):
    if request.method == 'OPTIONS':
        response.access_control_allow_headers = ['Authorization', 'Content-Type']
        response.access_control_allow_methods = response.allow
    response.access_control_allow_origin = request.origin or '*'
    return response

As you can see, each PHP and Flask endpoint simply takes the parsed query string, which becomes the $_GET array in PHP, and the request.args dictionary in Python, and returns the JSON representation of that data. Ideally, the data passed as params to http.get() should be returned exactly as we pass in the data. Since these are objects/mappings/dictionaries, order is not guaranteed. But content should be. (PHP appeared to leave the order intact, while my Flask testbed sorted the keys for caching or debug purposes.) I included ' ' in the character set to demonstrate how “+” and “%20” conflict.

expectedData

{
  "a@b": "c@d",
  "a:b": "c:d",
  "a$b": "c$d",
  "a,b": "c,d",
  "a;b": "c;d",
  "a+b": "c+d",
  "a=b": "c=d",
  "a?b": "c?d",
  "a/b": "c/d",
  "a b": "c d"
}

Default encoder (HttpUrlEncodingCodec)

The GET requests to the testbeds results in URLs with the following query string: ?a@b=c@d&a:b=c:d&a$b=c$d&a,b=c,d&a;b=c;d&a+b=c+d&a=b=c=d&a?b=c?d&a/b=c/d&a%20b=c%20d

phpData

{
  "a@b": "c@d",
  "a:b": "c:d",
  "a$b": "c$d",
  "a,b": "c,d",
  "a;b": "c;d",
  "a_b": "c d",
  "a": "b=c=d",
  "a?b": "c?d",
  "a/b": "c/d"
}

“+” in the query string is treated as a space. (Note that PHP appears to dislike spaces in the $_GET keys, and automatically converts them to underscores. This behavior is specific to PHP, and therefore not an issue to be corrected.) Since “+” is equivalent to " ", the "a b": "c d" pair becomes a duplicate. The key-pair “a=b=c=d” is interpreted as "a": "b=c=d", which was not the intent.

flaskData

{
  "a": "b=c=d",
  "a b": "c d",
  "a$b": "c$d",
  "a,b": "c,d",
  "a/b": "c/d",
  "a:b": "c:d",
  "a;b": "c;d",
  "a?b": "c?d",
  "a@b": "c@d"
}

Identical results, except that Flask/Python doesn’t mind a space in the key.

It should be noted here that none of the other characters seem to cause issue. So they’re “safe”, but I don’t know if that’s always guaranteed. What I do know is that by encoding the key-value pairs with encodeURIComponent() and any reserved characters percent-encoded, there’s zero chance that the characters are treated as reserved token characters. Testing with the URIComponentCodec implementation above, the data sent and returned to/from the testbeds is more complete and correct:

URIComponentCodec (using only encodeURIComponent())

The query string produced: ?a%40b=c%40d&a%3Ab=c%3Ad&a%24b=c%24d&a%2Cb=c%2Cd&a%3Bb=c%3Bd&a%2Bb=c%2Bd&a%3Db=c%3Dd&a%3Fb=c%3Fd&a%2Fb=c%2Fd&a%20b=c%20d (Yes, it’s very ugly.)

phpData

{
  "a@b": "c@d",
  "a:b": "c:d",
  "a$b": "c$d",
  "a,b": "c,d",
  "a;b": "c;d",
  "a+b": "c+d",
  "a=b": "c=d",
  "a?b": "c?d",
  "a/b": "c/d",
  "a_b": "c d"
}

Note that we have “a+b”, “a=b”, and “a_b” keys. All values are as expected.

flaskData

{
  "a b": "c d",
  "a$b": "c$d",
  "a+b": "c+d",
  "a,b": "c,d",
  "a/b": "c/d",
  "a:b": "c:d",
  "a;b": "c;d",
  "a=b": "c=d",
  "a?b": "c?d",
  "a@b": "c@d"
}

Note that we again have all keys including “a+b”, “a=b”, and “a b”. All keys and values are as expected.

Conclusion

I would propose that the entire “standard encoding” be thrown out, and encodeURIComponent() be used as-is. encodeURIComponent() is available specifically to encode URI components such as query string keys and values.

I don’t know the reasoning behind the “standard encoding”. I suspect it was for prettier query strings. But the HttpClient is used mainly for asynchronous background data requests. The user won’t see the query string. The server will, and the server needs to see the correct query string.

At a minimum, “+” and “=” should be left percent-encoded:

  • “+” because it is the standard to treat “+” as a space in a query string. A design decision made years ago when email was on a terminal, but it’s been that way forever (exhibits A, B, C, D). %20 and %2B are unambiguous.
  • “=” should be left encoded because the current implementation mangles any keys containing a “=”. It’s such an odd thing to do, so I doubt anyone has ever run into this issue. But it’s valid, and should be supported.

For those still curious about why a “+” should be a space – https://www.w3.org/Addressing/URL/uri-spec.html:

Choices for a universal syntax

… The use of white space characters is risky in URIs to be printed or sent by electronic mail, and the use of multiple white space characters is very risky. This is because of the frequent introduction of extraneous white space when lines are wrapped by systems such as mail, or sheer necessity of narrow column width, and because of the inter-conversion of various forms of white space which occurs during character code conversion and the transfer of text between applications. This is why the canonical form for URIs has all white spaces encoded.

Query strings

… Within the query string, the plus sign is reserved as shorthand notation for a space. Therefore, real plus signs must be encoded. This method was used to make query URIs easier to pass in systems which did not allow spaces.

A change like this would likely need to be released in a major release. It’s possibly breaking, if users are expecting their “+” to be spaces on the server side. Or if they’re expecting "a=b": "c" to be "a": "b=c"… though I think it’s safe to say that’s not clearly expected behavior.

I realize that was a lot of data and opinion. I’m very interested to know if purely percent-encoded values would break some server side API. I guess I’m openly asking - what’s the advantage to decoding some of the percent encoded values, other than making it less ugly? Why not leave the query string components percent-encoded?

Same issue here, I can’t send dates in ISO 8601 format to the API because the + sign is being replace by a blank space…

I don’t think ‘+’ should be encoded to ‘%2B’ by default, there is an standard that accepts ‘+’ as an space for urls, there should be an easy way to do it when you need it, for example using a custom encoder, but with some interceptors this generates the double encode problem. So maybe create an easy way to encode EVERY param, using a flag option or something similar.

I see that this is still an issue, anyone knows if Angular is working on a solution?

You can try the package I built to solve it: https://github.com/tiangolo/ngx-http-client


npm install --save ngx-http-client

And in your code, replace this line:

import {HttpClientModule} from '@angular/common/http';

with:

// import {HttpClientModule} from '@angular/common/http';

import {HttpClientModule} from 'ngx-http-client';

…that’s it. The rest of your code is the same. Let me know if it works (or not).

I’m having this same issue. I don’t believe it is related to the server or back-end at all…

After sending the post in angular you can find it being displayed in Chrome debug tools here.

2017-09-25 19_28_36-urltest

Do we know if the angular team is working on this?

This issue breaks GET request Query parameters as well.

The plus symbol must be properly encoded as %2B, it cannot be sent as unencoded + in URL query parameters - or it will be URL-decoded by servers to space character, thus any parameter with value like “A+B” will be URL-decoded to “A B”.

4 years later…

+1
I HATE THIS STUFF WE HAVE PHONE NUMBERS like +36…

xD ahem I mean… same issue here

I don’t think it’s an issue at the server side. Am using node with angular version 4.3.6 and having the same issue.

Addition to this issue: I have a param that is ISO8601 (2016-10-03T13:27:25+03:00)

UrlSearchParams.append and then toString() makes this + sign into spaces. Given that this issue here from Aug19th and complicated, is there good workaround for this?

@dylhunn Thanks for the reply.

I understand not wanting to break old code. I’ve been around enough myself to know how fragile it can be.

I think we would agree that most users don’t run into these issues. They’re sending a JSON body, or whatever else, and they simply don’t hit these edge cases. But when someone does, they inevitably google for a solution, end up here or StackOverflow, and they end up writing nearly identical work arounds.

It’s not difficult to create a custom implementation of HttpParameterCodec and create HttpParams with a custom encoder. When done in a wrapped service, a user can again pass in plain ol’ JavaScript objects as a params argument. Unfortunately, everyone trying to send ISO date times with a positive time zone offset as a parameter value is going to end up going down this same path.

So… What about making it easier for users to use an alternative encoder? If there were an option as simple as this.http.defaultParamEncoder = AlternativeEncodingCodec – it would be very nice. And an example alternative encoder could be included in params.ts, making it easy for users to toggle. Thoughts?

Update: Oh, and I’d be willing to contribute to do the work. Nothing comes for free, after all. 😃

Why angular’s URLSearchParams does not behave like the standard URLSearchParams (https://url.spec.whatwg.org/#urlsearchparams) by default ?

I tested angular with this test https://github.com/w3c/web-platform-tests/blob/master/url/urlsearchparams-stringifier.html :


import { URLSearchParams } from '@angular/http';

function test(fn: Function, text: string) {
    fn();
}

function assert_equals(o1: any, o2: any) {
    if (o1 !== o2) {
        console.error(o1 + ' should be equals to ' + o2);
    }
}

test(function () {
    var params = new URLSearchParams();
    params.append('a', 'b c');
    assert_equals(params + '', 'a=b+c');
    params.delete('a');
    params.append('a b', 'c');
    assert_equals(params + '', 'a+b=c');
}, 'Serialize space');
test(function() {
    var params = new URLSearchParams();
    params.append('a', '');
    assert_equals(params + '', 'a=');
    params.append('a', '');
    assert_equals(params + '', 'a=&a=');
    params.append('', 'b');
    assert_equals(params + '', 'a=&a=&=b');
    params.append('', '');
    assert_equals(params + '', 'a=&a=&=b&=');
    params.append('', '');
    assert_equals(params + '', 'a=&a=&=b&=&=');
}, 'Serialize empty value');
test(function() {
    var params = new URLSearchParams();
    params.append('', 'b');
    assert_equals(params + '', '=b');
    params.append('', 'b');
    assert_equals(params + '', '=b&=b');
}, 'Serialize empty name');
test(function() {
    var params = new URLSearchParams();
    params.append('', '');
    assert_equals(params + '', '=');
    params.append('', '');
    assert_equals(params + '', '=&=');
}, 'Serialize empty name and value');
test(function() {
    var params = new URLSearchParams();
    params.append('a', 'b+c');
    assert_equals(params + '', 'a=b%2Bc');
    params.delete('a');
    params.append('a+b', 'c');
    assert_equals(params + '', 'a%2Bb=c');
}, 'Serialize +');
test(function() {
    var params = new URLSearchParams();
    params.append('=', 'a');
    assert_equals(params + '', '%3D=a');
    params.append('b', '=');
    assert_equals(params + '', '%3D=a&b=%3D');
}, 'Serialize =');
test(function() {
    var params = new URLSearchParams();
    params.append('&', 'a');
    assert_equals(params + '', '%26=a');
    params.append('b', '&');
    assert_equals(params + '', '%26=a&b=%26');
}, 'Serialize &');
test(function() {
    var params = new URLSearchParams();
    params.append('a', '*-._');
    assert_equals(params + '', 'a=*-._');
    params.delete('a');
    params.append('*-._', 'c');
    assert_equals(params + '', '*-._=c');
}, 'Serialize *-._');
test(function() {
    var params = new URLSearchParams();
    params.append('a', 'b%c');
    assert_equals(params + '', 'a=b%25c');
    params.delete('a');
    params.append('a%b', 'c');
    assert_equals(params + '', 'a%25b=c');
}, 'Serialize %');
test(function() {
    var params = new URLSearchParams();
    params.append('a', 'b\0c');
    assert_equals(params + '', 'a=b%00c');
    params.delete('a');
    params.append('a\0b', 'c');
    assert_equals(params + '', 'a%00b=c');
}, 'Serialize \\0');
test(function() {
    var params = new URLSearchParams();
    params.append('a', 'b\uD83D\uDCA9c');
    assert_equals(params + '', 'a=b%F0%9F%92%A9c');
    params.delete('a');
    params.append('a\uD83D\uDCA9b', 'c');
    assert_equals(params + '', 'a%F0%9F%92%A9b=c');
}, 'Serialize \uD83D\uDCA9');  // Unicode Character 'PILE OF POO' (U+1F4A9)
test(function() {
    var params: URLSearchParams;
    params = new URLSearchParams('a=b&c=d&&e&&');
    assert_equals(params.toString(), 'a=b&c=d&e=');
    params = new URLSearchParams('a = b &a=b&c=d%20');
    assert_equals(params.toString(), 'a+=+b+&a=b&c=d+');
    // The lone '=' _does_ survive the roundtrip.
    params = new URLSearchParams('a=&a=b');
    assert_equals(params.toString(), 'a=&a=b');
}, 'URLSearchParams.toString');

The result :

a=b%20c should be equals to a=b+c
a%20b=c should be equals to a+b=c
a=b+c should be equals to a=b%2Bc
a+b=c should be equals to a%2Bb=c
==a should be equals to %3D=a
==a&b== should be equals to %3D=a&b=%3D
a=b&c=d&=&=&=&e= should be equals to a=b&c=d&e=
a%20=%20b%20&a=b&c=d%2520 should be equals to a+=+b+&a=b&c=d+

If you want to test in browser : http://w3c-test.org/url/urlsearchparams-stringifier.html

Seems it was fixed for Angular v15 Accordingly upgrade guide https://update.angular.io/?l=3&v=13.0-15.0

Sending + as part of a query no longer requires workarounds since + no longer sends a space.

Can somebody confirm this?

Yes, we fixed + but not =. It was too risky to do them simultaneously because there’s very poor test coverage for this kind of change, since it’s at the server-client boundary. The + proved to be low-drama, so we can probably accept a fix for = in the next major.

The behavior of = is a different question than the behavior +, and it’s not nearly as clear cut.

After some discussion, we are not even sure that the urlencoding spec for form data allows = in keys at all. This is just not suitable for a combined fix.

I think that the default HttpParameterCodec should strive to match the behavior of URLSearchParams.

That would be the least surprising (for developers) and easiest to integrate (when thinking of client-server interaction etc).

It has the additional bonus of being in a specification which can be looked up when debating what constraints are expected (such as legitimate key values).

IIUC the relevant part regarding your doubt is

URLSearchParams objects will percent-encode anything in the application/x-www-form-urlencoded percent-encode set, and will encode U+0020 SPACE as U+002B (+).

As mentioned here,

The application/x-www-form-urlencoded percent-encode set contains all code points, except the ASCII alphanumeric, U+002A (*), U+002D (-), U+002E (.), and U+005F (_).

Hence = (just like the other cases currently handled in a special way in angular) seems to be just fine in keys or values, they just need to be percent-encoded.

@dylhunn are you kidding? It’s a consolidated issue from angularjs time, a lot of other issues were just closed as duplicates in favor of this issue.

Also you can look into this comment https://github.com/angular/angular/issues/11058#issuecomment-636131153 by @alxhub

As it’s a breaking change, I think with all special characters we will see original issue with wrong implementation fixed probably near Angular 21 or 22

@spanevin almost all of the discussion here is about +. If = is also affected, we’d need a separate reproduction and issue, and we’d want to fix it in a separate PR, to minimize risk at one time.

There are currently a couple of PRs open for this bug (#37385 and #32598). The difficulty is finding an appropriate API and ensuring a reasonable migration path for the breaking change.

@atscott there is similar issue for the new Angular as well. Seems like you copy bugs from one implementation to another 😉 https://github.com/angular/angular/issues/18261

Same issue here.

I’m using URLSearchParams to prepare the parameters to post to the auth/token service. The ‘+’ char is replaced/encoded with a space so the authentication fails.

It’s unbelievable that this bug remains in angular 13 I’ve lost many hours debbuging my API and the bug was in the front end ¬¬

@dylhunn Why = was removed from the title?

Are you sure + is the only affected character? I don’t see a test for & in parameter value.

Also I don’t see a test for = in parameter name. I understand why it works without replacement in the value, but it will break name if it is not encoded properly.

Let’s say we have a parameter with name=“a=b” and value = “c&d=5” What do we have in current implementation? I think it’s …a=b=c&d=5 right? Which I, based on my knowledge, would parse as 2 parameters: “a” = “b=c” and “d” = “5” instead of “a=b” = “c&d=5” Am I wrong?

I don’t think ‘+’ should be encoded to ‘%2B’ by default, there is an standard that accepts ‘+’ as an space for urls, there should be an easy way to do it when you need it, for example using a custom encoder, but with some interceptors this generates the double encode problem. So maybe create an easy way to encode EVERY param, using a flag option or something similar.

I don’t understand. If + is an ENCODED SPACE, why do you suggest to not encode +? + is +, space is space. If somebody does pre-encoding, what’s the difference between + and any other character like % which can be a part of ENCODED string? If they do pre-encoding, they MUST disable integrated encoding. If then don’t do pre-encoding - + MUST be encoded.

@LinPoLen - thank you for editing your previous message and being aware of our code of conduct when interacting on the issue tracker.

@artfulsage Yep. I ended up using the codec part, though just creating a HttpParams using it inline.

Note that as mentioned in #33728 this issue isn’t limited to +. The = character for example is left alone even for encoding query param keys, which means a key of a=b incorrectly gets interpreted as a key of a with its value prefixed by b=, which is just totally wrong.

Thanks for reporting this issue. This issue is now obsolete due to changes in the recent releases. URLSearchParams is no longer part of Angular. Please update to the most recent Angular version.

If the problem still exists in your application, please open a new issue and follow the instructions in the issue template.

Ain’t what the PR #19711 is trying to address for us and it is just sitting there?

@dtwright I wrote a section explaining all the details I found in the package I created to solve the problem: https://github.com/tiangolo/ngx-http-client#very-technical-details

I am currently using the custom encoder solution but looking at the tags in this issue, it seems like this has been acknowledged as a bug.

Thanks for the clarification @trotyl .

I guess that both W3C/WHATWG and encodeURIComponent end up rendering a valid and “compatible” URL. At least, parseable by back ends without creating side effects.

But the current encoder is actually reversing it, it is taking the encoded version of +, that results from encodeURIComponent (and would be the same effect using the W3C/WHATWG encoding) and converting it back to a literal +.

%2B -> +

instead of leaving

+ -> %2B (that is compatible with both versions)

And the same for: @, :, $, ,, ;, +, =, ?, /.

I can imagine how it could generate problems having those characters being parsed by the back end literally, unencoded, in parameter keys or values. At least for: :, +, =, ?, /.

As of now, we have confirmed that it creates issues with PHP and Python back ends, from the comments above. I imagine there could be more back end languages and frameworks that would expect encoded params in a way that is compatible with at least one of W3C/WHATWG or encodeURIComponent.


I know this project is huge, with more than 250 PRs open (including a 2 months old one from me). So I don’t know if submitting a PR is actually appreciated. Especially before knowing if the changes would acceptable.

@alxhub could you give us a hint? What was the reason for overwriting the native encodeURIComponent? Would a PR removing one or more of those lines be acceptable / desired?

@tiangolo encodeURIComponent does not follow W3C/WHATWG standard as well, W3C/WHATWG Standard is: -> + & + -> %2B encodeURIComponent is: -> %20 & + -> %2B

There is nothing wrong to rewrite the result of encodeURIComponent, the problem is for how to rewrite it.

This also affects Flask back ends.

Flask follows the W3C standard and converts + characters to a “space” character ( ).

I could create a PR removing that line.

...
      .replace(/%2B/gi, '+')
...

…but first I would like to ask, is there a reason why you overrode the standard encodeURIComponent?

It seems like the code was clearly written to override the default encoding and override the encoding / escaping of reserved characters (like +).

@alxhub could you explain to us what was the idea / motivation in that code section and if you see any better approach to solving it? Git blame says you’re the person to ask…

Would it be acceptable if I create a PR removing the custom overriding and decoding for reserved characters?


Edit: I just noticed that, although the code is mostly the same and the problem is the same too, I’m talking about the @angualr/common/http/params module. The one used in Angular 4.3’s HttpClient.

Should I create an independent issue for that? Or should I just leave my comment here given that the problem is the same?

I still have this problem with the version of Angular    “dependencies”: {      “@ angular / animations”: “^ 4.4.0-RC.0”,      “@ angular / common”: “^ 4.4.0-RC.0”,      “@ angular / compiler”: “^ 4.4.0-RC.0”,      “@ angular / core”: “^ 4.4.0-RC.0”,      “@ angular / forms”: “^ 4.4.0-RC.0”,      “@ angular / http”: “^ 4.4.0-RC.0”,      “@ angular / platform-browser”: “^ 4.4.0-RC.0”,      “@ angular / platform-browser-dynamic”: “^ 4.4.0-RC.0”,      “@ angular / router”: “^ 4.4.0-RC.0”,

When I have a URL with “+” that is changed by% 2B a 404 error is thrown

http://www.condominio-mais.com/usuario/resetar/4995ffba-6e78-486e-8653-0fb8f4e6996e/CfDJ8KvZVIfag49BieUUcQkCHC0WR%2FQNYNjUtosCAOJ6V5VTJHn0phbIUB4zWt2wWX3rKBK3iwrktVIzeuuZ5bYOQYFMGSN2cFTkKx%2B7AcN0Uw7KcpPwkhU2fGDAMUssZMxwqNnoEPscVdZIQJlQ4NvbAyHSJk4I9g1wjZoTumSiT5VDDkebFvjild8RVoFtXYcsObpvpfgmxT3Cdld5h%2Fot3L5h3qkYSbfbg%2BxcE1VTxJb5saXQYWtnnDBJb4aBwH5zZg%3D%3D

@ayrad in your example you are using a http.post. Does it work with http.get as well?

The two methods have a different signature and I am afraid doesn’t work with the http.get.

as + becomes space in some PHP implementations.

Also in Tomcat, and thus in all the servers that use it behind the scenes.

Is it the same problem when navigating to an URL? When going to for example http://localhost:5000/?q=test+test the url suddenly http://localhost:5000/?q=test%2Btest

Thats the way in 2.0.0 as well…