Repository: pallets/werkzeug (python)

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





Pull request description:
encode plus in urls
The `urlencode` helper used when building URLs in the router and test client treated `+` as safe, but it has special meaning in query strings and should be percent-encoded.

fixes #2657 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 00eec338..3330f53f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,8 @@ Version 2.3.1
 
 Unreleased
 
+-   Percent-encode plus (+) when building URLs and in test requests. :issue:`2657`
+
 
 Version 2.3.0
 -------------
diff --git a/src/werkzeug/urls.py b/src/werkzeug/urls.py
index 13a67f34..b0e62d0b 100644
--- 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
index 08035963..00ad8b60 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -922,6 +922,7 @@ def test_build_values_multidict(endpoint, value, expect):
         ([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):