Repository: pallets/werkzeug

Issue:
plus (+) in query args converted to space
# Summary

In #2608 some functions were changed to use `urllib.parse.quote`. Note that there exists `urllib.parse.quote_plus`, which is almost the same, but handles the plus and space characters differently. I suspect, but am not certain, that werkzeug should be using `quote_plus` instead of `quote`. 

Code that was working for me yesterday with the older version of werkzeug is now not working with the newest version.


# Minimum Working Example

## Steps to reproduce

```
from flask import Flask, jsonify, request
import re

app = Flask(__name__)

@app.route('/v1/telemetry', methods=['GET'])
def telemetry():
    print(f"telemetry received")
    print(f"telemetry received args {request.args}")
    mystr = request.args['mystr']
    print(f"telemetry received {mystr}")
    pat = r"[a-z]\+[a-z]"
    assert re.match(pat, mystr)
    
    # XSS vulnerable of course
    # this is just a MWE
    return jsonify({'echo': mystr})

app.testing = True
app.debug = True


client = app.test_client()
resp = client.get('/v1/telemetry', query_string={'mystr': 'a+b'}) 
assert resp.status_code == 200
assert resp.json['echo'] == 'a+b'

```

```
pip3 install werkzeug==2.3.0 flask
```

```
python3 main.py
```

## Expected behavior

Script runs without error. The assertions passes.

## Actual Behavior

```
Traceback (most recent call last):
  File "main.py", line 23, in <module>
    resp = client.get('/v1/telemetry', query_string={'mystr': 'a+b'}) 
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/werkzeug/test.py", line 1241, in get
    return self.open(*args, **kw)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/testing.py", line 227, in open
    response = super().open(
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/werkzeug/test.py", line 1195, in open
    response = self.run_wsgi_app(request.environ, buffered=buffered)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/werkzeug/test.py", line 1069, in run_wsgi_app
    rv = run_wsgi_app(self.application, environ, buffered=buffered)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/werkzeug/test.py", line 1343, in run_wsgi_app
    app_rv = app(environ, start_response)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/app.py", line 2552, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/app.py", line 2532, in wsgi_app
    response = self.handle_exception(e)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/app.py", line 2529, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/app.py", line 1825, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/app.py", line 1823, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/ec2-user/environment/scratch/schemathesis/venv/lib/python3.8/site-packages/flask/app.py", line 1799, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "main.py", line 13, in telemetry
    assert re.match(pat, mystr)
AssertionError
```

If you downgrade to the previous version of werkzeug:

```
pip3 install werkzeug==2.2.3
```

Then it does work.
(The script runs without throwing any exceptions)

# Environment

- Python version: 3.8.11
- Werkzeug version: 
  -  2.2.3 - works
  -  2.3.0 - doesn't work






Discussion:
This bit me too by breaking base64 encoded payloads by replacing `+` with space. No changes on my end besides upgrade from 2.2.0 to 2.3. 
Just confirming that I'm seeing this too. The bug affects 2.3.0 and does not exist in 2.2.3. Other special characters are still being properly escaped.

An example to demonstrate one of the problems that it causes:

```python3
from flask import Flask, url_for, request

app = Flask(__name__)


@app.route('/')
def hello_world():
    return f'{url_for("hello_world", **request.args)}'

```

If I make a request to /?plus=1%2B1, I'd expect this to generate the same URL in response, but I actually get a literal plus in the URL instead of the escaped plus.

Resolving patch:
diff --git a/src/werkzeug/urls.py b/src/werkzeug/urls.py
--- a/src/werkzeug/urls.py
+++ b/src/werkzeug/urls.py
@@ -1367,4 +1367,4 @@ def _urlencode(
 ) -> str:
     items = [x for x in iter_multi_items(query) if x[1] is not None]
     # safe = https://url.spec.whatwg.org/#percent-encoded-bytes
-    return urlencode(items, safe="!$'()*+,/:;?@", encoding=encoding)
+    return urlencode(items, safe="!$'()*,/:;?@", encoding=encoding)


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -922,6 +922,7 @@ def to_url(self, value: t.Any) -> str:
         ([1, 2], "?v=1&v=2"),
         ([1, None, 2], "?v=1&v=2"),
         ([1, "", 2], "?v=1&v=&v=2"),
+        ("1+2", "?v=1%2B2"),
     ],
 )
 def test_build_append_unknown_dict(value, expect):