Skip to content

Commit b278f61

Browse files
authored
Fix invalid boundary (pypi#4572)
1 parent 4b7d0c2 commit b278f61

File tree

4 files changed

+260
-143
lines changed

4 files changed

+260
-143
lines changed

tests/unit/test_config.py

Lines changed: 1 addition & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -16,79 +16,13 @@
1616
import pytest
1717

1818
from pyramid import renderers
19-
from pyramid.request import Request
2019
from pyramid.tweens import EXCVIEW
2120

2221
from warehouse import config
2322
from warehouse.errors import BasicAuthBreachedPassword
2423
from warehouse.utils.wsgi import ProxyFixer, VhmRootRemover, HostRewrite
2524

2625

27-
class TestJunkEncodingTween:
28-
def test_valid(self):
29-
response = pretend.stub()
30-
handler = pretend.call_recorder(lambda request: response)
31-
32-
tween = config.junk_encoding_tween_factory(handler, pretend.stub())
33-
34-
request = Request({"QUERY_STRING": ":action=browse", "PATH_INFO": "/pypi"})
35-
resp = tween(request)
36-
37-
assert resp is response
38-
39-
def test_invalid_qsl(self):
40-
response = pretend.stub()
41-
handler = pretend.call_recorder(lambda request: response)
42-
43-
tween = config.junk_encoding_tween_factory(handler, pretend.stub())
44-
45-
request = Request({"QUERY_STRING": "%Aaction=browse"})
46-
resp = tween(request)
47-
48-
assert resp is not response
49-
assert resp.status_code == 400
50-
assert resp.detail == "Invalid bytes in query string."
51-
52-
def test_invalid_path(self):
53-
response = pretend.stub()
54-
handler = pretend.call_recorder(lambda request: response)
55-
56-
tween = config.junk_encoding_tween_factory(handler, pretend.stub())
57-
58-
request = Request({"PATH_INFO": "/projects/abouÅt"})
59-
resp = tween(request)
60-
61-
assert resp is not response
62-
assert resp.status_code == 400
63-
assert resp.detail == "Invalid bytes in URL."
64-
65-
66-
class TestUnicodeRedirectTween:
67-
def test_basic_redirect(self):
68-
response = pretend.stub(location="/a/path/to/nowhere")
69-
handler = pretend.call_recorder(lambda request: response)
70-
registry = pretend.stub()
71-
tween = config.unicode_redirect_tween_factory(handler, registry)
72-
request = pretend.stub(path="/A/pAtH/tO/nOwHeRe/")
73-
assert tween(request) == response
74-
75-
def test_unicode_basic_redirect(self):
76-
response = pretend.stub(location="/pypi/\u2603/json/")
77-
handler = pretend.call_recorder(lambda request: response)
78-
registry = pretend.stub()
79-
tween = config.unicode_redirect_tween_factory(handler, registry)
80-
request = pretend.stub(path="/pypi/snowman/json/")
81-
assert tween(request).location == "/pypi/%E2%98%83/json/"
82-
83-
def test_not_redirect(self):
84-
response = pretend.stub(location=None)
85-
handler = pretend.call_recorder(lambda request: response)
86-
registry = pretend.stub()
87-
tween = config.unicode_redirect_tween_factory(handler, registry)
88-
request = pretend.stub(path="/wu/tang/")
89-
assert tween(request) == response
90-
91-
9226
class TestRequireHTTPSTween:
9327
def test_noops_when_disabled(self):
9428
handler = pretend.stub()
@@ -393,6 +327,7 @@ def __init__(self):
393327
pretend.call(".http"),
394328
]
395329
+ [pretend.call(x) for x in [configurator_settings.get("warehouse.theme")] if x]
330+
+ [pretend.call(".sanity")]
396331
)
397332
assert configurator_obj.add_jinja2_renderer.calls == [
398333
pretend.call(".html"),
@@ -420,22 +355,6 @@ def __init__(self):
420355
add_settings_dict = configurator_obj.add_settings.calls[2].args[0]
421356
assert add_settings_dict["tm.manager_hook"](pretend.stub()) is transaction_manager
422357
assert configurator_obj.add_tween.calls == [
423-
pretend.call(
424-
"warehouse.config.junk_encoding_tween_factory",
425-
over=[
426-
"warehouse.referrer_policy.referrer_policy_tween_factory",
427-
"warehouse.config.require_https_tween_factory",
428-
"warehouse.config.unicode_redirect_tween_factory",
429-
"warehouse.csp.content_security_policy_tween_factory",
430-
"warehouse.static.whitenoise_tween_factory",
431-
"warehouse.utils.compression.compression_tween_factory",
432-
"warehouse.raven.raven_tween_factory",
433-
"pyramid_tm.tm_tween_factory",
434-
"pyramid.tweens.excview_tween_factory",
435-
"warehouse.cache.http.conditional_http_tween_factory",
436-
],
437-
),
438-
pretend.call("warehouse.config.unicode_redirect_tween_factory"),
439358
pretend.call("warehouse.config.require_https_tween_factory"),
440359
pretend.call(
441360
"warehouse.utils.compression.compression_tween_factory",

tests/unit/test_sanity.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import io
14+
15+
import pretend
16+
import pytest
17+
18+
from pyramid.httpexceptions import HTTPMovedPermanently, HTTPBadRequest
19+
from pyramid.request import Request
20+
from pyramid.response import Response
21+
22+
from warehouse import sanity
23+
24+
25+
class TestJunkEncoding:
26+
def test_valid(self):
27+
request = Request({"QUERY_STRING": ":action=browse", "PATH_INFO": "/pypi"})
28+
sanity.junk_encoding(request)
29+
30+
def test_invalid_qsl(self):
31+
request = Request({"QUERY_STRING": "%Aaction=browse"})
32+
33+
with pytest.raises(HTTPBadRequest, match="Invalid bytes in query string."):
34+
sanity.junk_encoding(request)
35+
36+
def test_invalid_path(self):
37+
request = Request({"PATH_INFO": "/projects/abouÅt"})
38+
39+
with pytest.raises(HTTPBadRequest, match="Invalid bytes in URL."):
40+
sanity.junk_encoding(request)
41+
42+
43+
class TestInvalidForms:
44+
def test_valid(self):
45+
request = Request(
46+
{
47+
"REQUEST_METHOD": "POST",
48+
"CONTENT_TYPE": (
49+
"multipart/form-data; boundary=c397e2aa2980f1a53dee37c05b8fb45a"
50+
),
51+
"wsgi.input": io.BytesIO(
52+
b"--------------------------c397e2aa2980f1a53dee37c05b8fb45a\r\n"
53+
b'Content-Disposition: form-data; name="person"\r\n'
54+
b"anonymous"
55+
),
56+
}
57+
)
58+
59+
sanity.invalid_forms(request)
60+
61+
def test_invalid_form(self):
62+
request = Request(
63+
{
64+
"REQUEST_METHOD": "POST",
65+
"CONTENT_TYPE": ("multipart/form-data"),
66+
"wsgi.input": io.BytesIO(
67+
b'Content-Disposition: form-data; name="person"\r\n' b"anonymous"
68+
),
69+
}
70+
)
71+
72+
with pytest.raises(HTTPBadRequest, match="Invalid Form Data."):
73+
sanity.invalid_forms(request)
74+
75+
def test_not_post(self):
76+
request = Request({"REQUEST_METHOD": "GET"})
77+
sanity.invalid_forms(request)
78+
79+
80+
@pytest.mark.parametrize(
81+
("original_location", "expected_location"),
82+
[
83+
("/a/path/to/nowhere", "/a/path/to/nowhere"),
84+
("/project/☃/", "/project/%E2%98%83/"),
85+
(None, None),
86+
],
87+
)
88+
def test_unicode_redirects(original_location, expected_location):
89+
if original_location:
90+
resp_in = HTTPMovedPermanently(original_location)
91+
else:
92+
resp_in = Response()
93+
94+
resp_out = sanity.unicode_redirects(resp_in)
95+
96+
assert resp_out.location == expected_location
97+
98+
99+
class TestSanityTween:
100+
def test_ingress_valid(self, monkeypatch):
101+
junk_encoding = pretend.call_recorder(lambda request: None)
102+
monkeypatch.setattr(sanity, "junk_encoding", junk_encoding)
103+
104+
invalid_forms = pretend.call_recorder(lambda request: None)
105+
monkeypatch.setattr(sanity, "invalid_forms", invalid_forms)
106+
107+
response = pretend.stub()
108+
handler = pretend.call_recorder(lambda request: response)
109+
registry = pretend.stub()
110+
111+
request = pretend.stub()
112+
113+
tween = sanity.sanity_tween_factory_ingress(handler, registry)
114+
115+
assert tween(request) is response
116+
assert junk_encoding.calls == [pretend.call(request)]
117+
assert invalid_forms.calls == [pretend.call(request)]
118+
assert handler.calls == [pretend.call(request)]
119+
120+
def test_ingress_invalid(self, monkeypatch):
121+
response = HTTPBadRequest()
122+
123+
@pretend.call_recorder
124+
def junk_encoding(request):
125+
raise response
126+
127+
monkeypatch.setattr(sanity, "junk_encoding", junk_encoding)
128+
129+
handler = pretend.call_recorder(lambda request: response)
130+
registry = pretend.stub()
131+
132+
request = pretend.stub()
133+
134+
tween = sanity.sanity_tween_factory_ingress(handler, registry)
135+
136+
assert tween(request) is response
137+
assert junk_encoding.calls == [pretend.call(request)]
138+
assert handler.calls == []
139+
140+
def test_egress(self, monkeypatch):
141+
unicode_redirects = pretend.call_recorder(lambda resp: resp)
142+
monkeypatch.setattr(sanity, "unicode_redirects", unicode_redirects)
143+
144+
response = pretend.stub()
145+
handler = pretend.call_recorder(lambda request: response)
146+
registry = pretend.stub()
147+
148+
request = pretend.stub()
149+
150+
tween = sanity.sanity_tween_factory_egress(handler, registry)
151+
152+
assert tween(request) is response
153+
assert handler.calls == [pretend.call(request)]
154+
assert unicode_redirects.calls == [pretend.call(response)]

warehouse/config.py

Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@
1313
import enum
1414
import os
1515
import shlex
16-
import urllib.parse
1716

1817
import transaction
1918

2019
from pyramid import renderers
2120
from pyramid.config import Configurator as _Configurator
22-
from pyramid.httpexceptions import HTTPBadRequest
2321
from pyramid.response import Response
2422
from pyramid.security import Allow, Authenticated
2523
from pyramid.tweens import EXCVIEW
@@ -63,47 +61,6 @@ def __init__(self, request):
6361
pass
6462

6563

66-
def junk_encoding_tween_factory(handler, request):
67-
def junk_encoding_tween(request):
68-
# We're going to test our request a bit, before we pass it into the
69-
# handler. This will let us return a better error than a 500 if we
70-
# can't decode these.
71-
72-
# Ref: https://github.com/Pylons/webob/issues/161
73-
# Ref: https://github.com/Pylons/webob/issues/115
74-
try:
75-
request.GET.get("", None)
76-
except UnicodeDecodeError:
77-
return HTTPBadRequest("Invalid bytes in query string.")
78-
79-
# Look for invalid bytes in a path.
80-
try:
81-
request.path_info
82-
except UnicodeDecodeError:
83-
return HTTPBadRequest("Invalid bytes in URL.")
84-
85-
# Everything worked! Handle this request as normal.
86-
return handler(request)
87-
88-
return junk_encoding_tween
89-
90-
91-
def unicode_redirect_tween_factory(handler, request):
92-
def unicode_redirect_tween(request):
93-
response = handler(request)
94-
if response.location:
95-
try:
96-
response.location.encode("ascii")
97-
except UnicodeEncodeError:
98-
response.location = "/".join(
99-
[urllib.parse.quote_plus(x) for x in response.location.split("/")]
100-
)
101-
102-
return response
103-
104-
return unicode_redirect_tween
105-
106-
10764
def require_https_tween_factory(handler, registry):
10865

10966
if not registry.settings.get("enforce_https", True):
@@ -265,24 +222,6 @@ def configure(settings=None):
265222
config = Configurator(settings=settings)
266223
config.set_root_factory(RootFactory)
267224

268-
# Add some fixups for some encoding/decoding issues
269-
config.add_tween(
270-
"warehouse.config.junk_encoding_tween_factory",
271-
over=[
272-
"warehouse.referrer_policy.referrer_policy_tween_factory",
273-
"warehouse.config.require_https_tween_factory",
274-
"warehouse.config.unicode_redirect_tween_factory",
275-
"warehouse.csp.content_security_policy_tween_factory",
276-
"warehouse.static.whitenoise_tween_factory",
277-
"warehouse.utils.compression.compression_tween_factory",
278-
"warehouse.raven.raven_tween_factory",
279-
"pyramid_tm.tm_tween_factory",
280-
"pyramid.tweens.excview_tween_factory",
281-
"warehouse.cache.http.conditional_http_tween_factory",
282-
],
283-
)
284-
config.add_tween("warehouse.config.unicode_redirect_tween_factory")
285-
286225
# Register support for services
287226
config.include("pyramid_services")
288227

@@ -527,6 +466,11 @@ def configure(settings=None):
527466
ignore=["warehouse.migrations.env", "warehouse.celery", "warehouse.wsgi"]
528467
)
529468

469+
# Sanity check our request and responses.
470+
# Note: It is very important that this go last. We need everything else that might
471+
# have added a tween to be registered prior to this.
472+
config.include(".sanity")
473+
530474
# Finally, commit all of our changes
531475
config.commit()
532476

0 commit comments

Comments
 (0)