fresco 3.3.2__py3-none-any.whl → 3.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fresco might be problematic. Click here for more details.
- fresco/__init__.py +1 -1
- fresco/request.py +2 -2
- fresco/response.py +1 -1
- fresco/tests/__init__.py +0 -0
- fresco/tests/fixtures.py +67 -0
- fresco/tests/test_cookie.py +59 -0
- fresco/tests/test_core.py +1038 -0
- fresco/tests/test_decorators.py +40 -0
- fresco/tests/test_exceptions.py +30 -0
- fresco/tests/test_middleware.py +92 -0
- fresco/tests/test_multidict.py +234 -0
- fresco/tests/test_options.py +314 -0
- fresco/tests/test_request.py +448 -0
- fresco/tests/test_requestcontext.py +107 -0
- fresco/tests/test_response.py +224 -0
- fresco/tests/test_routeargs.py +223 -0
- fresco/tests/test_routing.py +1126 -0
- fresco/tests/test_static.py +124 -0
- fresco/tests/test_subrequests.py +236 -0
- fresco/tests/util/__init__.py +0 -0
- fresco/tests/util/form_data.py +79 -0
- fresco/tests/util/test_common.py +34 -0
- fresco/tests/util/test_http.py +323 -0
- fresco/tests/util/test_security.py +34 -0
- fresco/tests/util/test_urls.py +176 -0
- fresco/tests/util/test_wsgi.py +107 -0
- fresco/util/contentencodings.py +2 -1
- fresco/util/http.py +3 -1
- fresco/util/wsgi.py +1 -1
- {fresco-3.3.2.dist-info → fresco-3.3.3.dist-info}/METADATA +5 -6
- fresco-3.3.3.dist-info/RECORD +57 -0
- {fresco-3.3.2.dist-info → fresco-3.3.3.dist-info}/WHEEL +1 -1
- fresco-3.3.2.dist-info/RECORD +0 -34
- {fresco-3.3.2.dist-info → fresco-3.3.3.dist-info}/LICENSE.txt +0 -0
- {fresco-3.3.2.dist-info → fresco-3.3.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# encoding=UTF-8
|
|
2
|
+
# Copyright 2015 Oliver Cope
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
#
|
|
16
|
+
# See LICENSE.txt for terms of redistribution and use.
|
|
17
|
+
|
|
18
|
+
from io import BytesIO
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from hypothesis import given, strategies as st
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from fresco import FrescoApp
|
|
26
|
+
from fresco import context
|
|
27
|
+
from fresco import Response
|
|
28
|
+
from fresco.exceptions import RequestParseError
|
|
29
|
+
from fresco.routing import POST
|
|
30
|
+
from fresco.util.http import FileUpload
|
|
31
|
+
from fresco.util.http import PostParser
|
|
32
|
+
from fresco.util.http import encode_multipart
|
|
33
|
+
from fresco.util.http import parse_parameters
|
|
34
|
+
from fresco.util.http import parse_post
|
|
35
|
+
from fresco.util.http import parse_querystring
|
|
36
|
+
|
|
37
|
+
from . import form_data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestParseQueryString(object):
|
|
41
|
+
def p(self, value):
|
|
42
|
+
return list(parse_querystring(value))
|
|
43
|
+
|
|
44
|
+
def test_empty(self):
|
|
45
|
+
self.p("") == []
|
|
46
|
+
|
|
47
|
+
def test_simple_key_value(self):
|
|
48
|
+
assert self.p("a=b") == [("a", "b")]
|
|
49
|
+
|
|
50
|
+
def test_key_with_space(self):
|
|
51
|
+
assert self.p("a+b=c") == [("a b", "c")]
|
|
52
|
+
|
|
53
|
+
def test_value_with_space(self):
|
|
54
|
+
assert self.p("a=b+c") == [("a", "b c")]
|
|
55
|
+
|
|
56
|
+
def test_double_equals(self):
|
|
57
|
+
assert self.p("a==b") == [("a", "=b")]
|
|
58
|
+
|
|
59
|
+
def test_escaped_chars(self):
|
|
60
|
+
assert self.p("%20==c%3D") == [(" ", "=c=")]
|
|
61
|
+
|
|
62
|
+
def test_charset(self):
|
|
63
|
+
assert self.p("a=el%20ni%C3%B1o") == [("a", "el niño")]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestParseMultipart(object):
|
|
67
|
+
def _make_env(self, data: bytes):
|
|
68
|
+
data = b"----XX\r\n" + data + b"----XX--\r\n"
|
|
69
|
+
return (
|
|
70
|
+
BytesIO(data),
|
|
71
|
+
{
|
|
72
|
+
"CONTENT_LENGTH": len(data),
|
|
73
|
+
"CONTENT_TYPE": "multipart/form-data; boundary=--XX",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_multipart(self):
|
|
78
|
+
for data in form_data.multipart_samples:
|
|
79
|
+
io = BytesIO(data["data"]) # type: ignore
|
|
80
|
+
io.seek(0)
|
|
81
|
+
environ = {
|
|
82
|
+
"CONTENT_LENGTH": data["content_length"],
|
|
83
|
+
"CONTENT_TYPE": data["content_type"],
|
|
84
|
+
}
|
|
85
|
+
with PostParser(environ, io, "UTF-8") as parsed:
|
|
86
|
+
parsed = sorted(list(parsed))
|
|
87
|
+
|
|
88
|
+
assert [name for name, value in parsed] == [
|
|
89
|
+
"empty-text-input",
|
|
90
|
+
"file-upload",
|
|
91
|
+
"text-input-ascii",
|
|
92
|
+
"text-input-unicode",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
assert parsed[0] == ("empty-text-input", "")
|
|
96
|
+
assert parsed[2] == ("text-input-ascii", "abcdef")
|
|
97
|
+
assert parsed[3] == (
|
|
98
|
+
"text-input-unicode",
|
|
99
|
+
b"\xce\xb1\xce\xb2\xce\xb3\xce\xb4".decode("utf8"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
fieldname, fileupload = parsed[1]
|
|
103
|
+
assert isinstance(fileupload, FileUpload)
|
|
104
|
+
assert fieldname == "file-upload"
|
|
105
|
+
assert fileupload.filename == "test.data"
|
|
106
|
+
assert fileupload.headers["content-type"] == "application/octet-stream"
|
|
107
|
+
assert fileupload.file.read() == form_data.FILE_UPLOAD_DATA
|
|
108
|
+
|
|
109
|
+
def test_non_ascii_headers(self):
|
|
110
|
+
"""
|
|
111
|
+
Ensure that headers containing non-ascii characters are handled.
|
|
112
|
+
NB the stdlib email.parser module that we use for parsing replaces
|
|
113
|
+
any 8-bit characters with the unicode replacement character.
|
|
114
|
+
"""
|
|
115
|
+
filename = "café.txt".encode("utf8")
|
|
116
|
+
data = (
|
|
117
|
+
b"----XXXXXX\r\n"
|
|
118
|
+
b'Content-Disposition: form-data; name="u"; '
|
|
119
|
+
b'filename="' + filename + b'"\r\n'
|
|
120
|
+
b"Content-Type: application/octet-stream\r\n\r\n"
|
|
121
|
+
b"1234567890\r\n"
|
|
122
|
+
b"----XXXXXX--\r\n"
|
|
123
|
+
)
|
|
124
|
+
environ = {
|
|
125
|
+
"CONTENT_LENGTH": len(data),
|
|
126
|
+
"CONTENT_TYPE": "multipart/form-data; boundary=--XXXXXX",
|
|
127
|
+
}
|
|
128
|
+
f = BytesIO(data)
|
|
129
|
+
with PostParser(environ, f, "UTF-8") as parsed:
|
|
130
|
+
fieldname, fileupload = list(parsed)[0]
|
|
131
|
+
assert isinstance(fileupload, FileUpload)
|
|
132
|
+
assert fileupload.filename == "caf\uFFFD\uFFFD.txt"
|
|
133
|
+
|
|
134
|
+
def test_malformed_headers_raise_exceptions(self):
|
|
135
|
+
# Missing '=' in parameter
|
|
136
|
+
data, env = self._make_env(
|
|
137
|
+
b"Content-Disposition: form-data; name; \r\n\r\n" b"1234567890\r\n"
|
|
138
|
+
)
|
|
139
|
+
with pytest.raises(RequestParseError):
|
|
140
|
+
list(parse_post(env, data))
|
|
141
|
+
|
|
142
|
+
def test_fileupload_too_big(self):
|
|
143
|
+
"""\
|
|
144
|
+
Verify that multipart/form-data encoded POST data raises an exception
|
|
145
|
+
if the total data size exceeds request.MAX_SIZE bytes
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def view():
|
|
149
|
+
request = context.request
|
|
150
|
+
request.MAX_MULTIPART_SIZE = 500
|
|
151
|
+
request.get("f1")
|
|
152
|
+
return Response(["ok"])
|
|
153
|
+
|
|
154
|
+
app = FrescoApp()
|
|
155
|
+
app.route("/", POST, view)
|
|
156
|
+
|
|
157
|
+
with app.requestcontext_post(
|
|
158
|
+
"/", files=[("f1", "filename.txt", "text/plain", "x" * 1000)]
|
|
159
|
+
):
|
|
160
|
+
assert app.view().status == "413 Payload Too Large"
|
|
161
|
+
|
|
162
|
+
with app.requestcontext_post(
|
|
163
|
+
"/",
|
|
164
|
+
files=[("f1", "filename.txt", "text/plain", "x" * 400)],
|
|
165
|
+
data={"f2": "x" * 101},
|
|
166
|
+
):
|
|
167
|
+
assert app.view().status == "413 Payload Too Large"
|
|
168
|
+
|
|
169
|
+
def test_fileupload_with_invalid_content_length(self):
|
|
170
|
+
def view():
|
|
171
|
+
request = context.request
|
|
172
|
+
request.get("f1")
|
|
173
|
+
return Response(["ok"])
|
|
174
|
+
|
|
175
|
+
app = FrescoApp()
|
|
176
|
+
app.route("/", POST, view)
|
|
177
|
+
|
|
178
|
+
with app.requestcontext_post(
|
|
179
|
+
"/", files=[("f1", "filename.txt", "text/plain", "x" * 1000)]
|
|
180
|
+
) as c:
|
|
181
|
+
c.request.environ["CONTENT_LENGTH"] = str("500")
|
|
182
|
+
assert app.view().status == "400 Bad Request"
|
|
183
|
+
|
|
184
|
+
def test_multipart_field_too_big(self):
|
|
185
|
+
"""
|
|
186
|
+
Verify that multipart/form-data encoded POST data raises an exception
|
|
187
|
+
if it contains a single field exceeding request.MAX_SIZE bytes
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def view():
|
|
191
|
+
request = context.request
|
|
192
|
+
request.MAX_MULTIPART_SIZE = 500
|
|
193
|
+
request.MAX_SIZE = 100
|
|
194
|
+
request.get("f1")
|
|
195
|
+
return Response(["ok"])
|
|
196
|
+
|
|
197
|
+
app = FrescoApp()
|
|
198
|
+
app.route("/", POST, view)
|
|
199
|
+
|
|
200
|
+
with app.requestcontext_post("/", multipart=True, data=[("f1", "x" * 200)]):
|
|
201
|
+
assert app.view().status == "413 Payload Too Large"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestParseFormEncodedData(object):
|
|
205
|
+
char_latin1 = b"\xa3"
|
|
206
|
+
char_utf8 = b"\xc2\xa3"
|
|
207
|
+
char = char_latin1.decode("latin1")
|
|
208
|
+
|
|
209
|
+
assert char_latin1.decode("latin1") == char_utf8.decode("utf8") == char
|
|
210
|
+
|
|
211
|
+
def test_formencoded_data_too_big(self):
|
|
212
|
+
"""
|
|
213
|
+
Verify that application/x-www-form-urlencoded POST data raises an
|
|
214
|
+
exception if it exceeds request.MAX_SIZE bytes
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def view():
|
|
218
|
+
request = context.request
|
|
219
|
+
request.MAX_SIZE = 100
|
|
220
|
+
request.get("f1")
|
|
221
|
+
return Response(["ok"])
|
|
222
|
+
|
|
223
|
+
app = FrescoApp()
|
|
224
|
+
app.route("/", POST, view)
|
|
225
|
+
|
|
226
|
+
with app.requestcontext_post("/", data=[("f1", "x" * 200)]):
|
|
227
|
+
assert app.view().status == "413 Payload Too Large"
|
|
228
|
+
|
|
229
|
+
def test_posted_data_contains_non_ascii_chars(self):
|
|
230
|
+
"""
|
|
231
|
+
Verify that it raises an exception if POST data contains invalid
|
|
232
|
+
characters.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def view():
|
|
236
|
+
context.request.get("f1")
|
|
237
|
+
|
|
238
|
+
app = FrescoApp()
|
|
239
|
+
app.route("/", POST, view)
|
|
240
|
+
|
|
241
|
+
data = "foo=bár".encode("utf8")
|
|
242
|
+
|
|
243
|
+
with app.requestcontext(
|
|
244
|
+
"/",
|
|
245
|
+
REQUEST_METHOD="POST",
|
|
246
|
+
CONTENT_LENGTH=str(len(data)),
|
|
247
|
+
CONTENT_TYPE="application/x-www-form-urlencoded",
|
|
248
|
+
wsgi_input=data,
|
|
249
|
+
):
|
|
250
|
+
response = app.view()
|
|
251
|
+
assert response.status == "400 Bad Request"
|
|
252
|
+
|
|
253
|
+
def test_non_utf8_data_posted(self):
|
|
254
|
+
data = b"char=" + quote(self.char_latin1).encode("ascii")
|
|
255
|
+
env = {
|
|
256
|
+
"REQUEST_METHOD": "POST",
|
|
257
|
+
"CONTENT_LENGTH": str(len(data)),
|
|
258
|
+
"wsgi.input": BytesIO(data),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
with FrescoApp().requestcontext(environ=env) as c:
|
|
262
|
+
request = c.request
|
|
263
|
+
request.charset = "latin1"
|
|
264
|
+
assert request.form["char"] == self.char
|
|
265
|
+
|
|
266
|
+
def test_non_utf8_data_getted(self):
|
|
267
|
+
data = "char=" + quote(self.char_latin1)
|
|
268
|
+
env = {"REQUEST_METHOD": "GET", "QUERY_STRING": data}
|
|
269
|
+
|
|
270
|
+
with FrescoApp().requestcontext(environ=env) as c:
|
|
271
|
+
request = c.request
|
|
272
|
+
request.charset = "latin1"
|
|
273
|
+
assert request.query["char"] == self.char
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TestEncodeMultipart(object):
|
|
277
|
+
def test_it_encodes_a_data_dict(self):
|
|
278
|
+
data, headers = encode_multipart([("foo", "bar baf")])
|
|
279
|
+
data = data.getvalue()
|
|
280
|
+
assert b'Content-Disposition: form-data; name="foo"\r\n\r\nbar baf' in data
|
|
281
|
+
|
|
282
|
+
def test_it_encodes_a_file_tuple(self):
|
|
283
|
+
data, headers = encode_multipart(files=[("foo", "foo.txt", "ascii", "bar")])
|
|
284
|
+
data = data.getvalue()
|
|
285
|
+
expected = (
|
|
286
|
+
b"Content-Disposition: form-data; "
|
|
287
|
+
b'name="foo"; filename="foo.txt"\r\n'
|
|
288
|
+
b"Content-Type: ascii\r\n"
|
|
289
|
+
b"\r\n"
|
|
290
|
+
b"bar"
|
|
291
|
+
)
|
|
292
|
+
assert expected in data
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TestParseParameters(object):
|
|
296
|
+
token = st.text(
|
|
297
|
+
alphabet="".join(
|
|
298
|
+
chr(c) for c in range(33, 127) if chr(c) not in '()<>@,;:\\/[]?="'
|
|
299
|
+
),
|
|
300
|
+
min_size=1,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@given(items=st.lists(st.tuples(token, token)))
|
|
304
|
+
def test_parse_tokens(self, items):
|
|
305
|
+
s = ";".join("{}={}".format(k, v) for k, v in items)
|
|
306
|
+
assert parse_parameters(s) == dict(items)
|
|
307
|
+
|
|
308
|
+
@given(items=st.lists(st.tuples(token, st.text(min_size=1))))
|
|
309
|
+
def test_parse_parameters(self, items):
|
|
310
|
+
def escape(s):
|
|
311
|
+
return re.sub(r'(["\\\r])', r"\\\1", s)
|
|
312
|
+
|
|
313
|
+
s = ";".join('{}="{}"'.format(k, escape(v)) for k, v in items)
|
|
314
|
+
assert parse_parameters(s) == dict(items)
|
|
315
|
+
|
|
316
|
+
def test_semicolons_in_parameters(self):
|
|
317
|
+
assert parse_parameters('name=";"') == {"name": ";"}
|
|
318
|
+
|
|
319
|
+
def test_quoted_strings_in_parameters(self):
|
|
320
|
+
assert parse_parameters('name="\\""') == {"name": '"'}
|
|
321
|
+
|
|
322
|
+
def test_it_preserves_backslashes(self):
|
|
323
|
+
assert parse_parameters('f="C:\\a.txt"', True) == {"f": "C:\\a.txt"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Copyright 2015 Oliver Cope
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from fresco.util.security import check_equal_constant_time
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestSecurity(object):
|
|
21
|
+
def test_check_equal_constant_time_returns_equality(self):
|
|
22
|
+
assert check_equal_constant_time("", "") is True
|
|
23
|
+
assert check_equal_constant_time("abcabcabc", "abcabcabc") is True
|
|
24
|
+
|
|
25
|
+
def test_check_equal_constant_time_returns_inequality(self):
|
|
26
|
+
assert check_equal_constant_time(" ", "") is False
|
|
27
|
+
assert check_equal_constant_time("abcabcabc", "abcabcabx") is False
|
|
28
|
+
assert check_equal_constant_time("abcabcabc", "abcabcabx") is False
|
|
29
|
+
assert check_equal_constant_time("abcabcabc", "abcabcabde") is False
|
|
30
|
+
assert check_equal_constant_time("abcabcabc", "abcabcab") is False
|
|
31
|
+
|
|
32
|
+
def test_check_equal_constant_time_raises_an_error_on_non_strings(self):
|
|
33
|
+
with pytest.raises(TypeError):
|
|
34
|
+
check_equal_constant_time(None, None)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Copyright 2015 Oliver Cope
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
from fresco.core import FrescoApp
|
|
16
|
+
from fresco.util.urls import normpath, make_query
|
|
17
|
+
from fresco.util.urls import is_safe_url
|
|
18
|
+
|
|
19
|
+
# Greek letters as unicode strings (require multi-byte representation in UTF-8)
|
|
20
|
+
alpha = b"\xce\xb1".decode("utf8")
|
|
21
|
+
beta = b"\xce\xb2".decode("utf8")
|
|
22
|
+
gamma = b"\xce\xb3".decode("utf8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestNormPath(object):
|
|
26
|
+
def test_empty_string(selfself):
|
|
27
|
+
assert normpath("") == ""
|
|
28
|
+
|
|
29
|
+
def test_root_url(self):
|
|
30
|
+
assert normpath("/") == "/"
|
|
31
|
+
|
|
32
|
+
def test_condenses_consecutive_slashes(self):
|
|
33
|
+
assert normpath("//") == "/"
|
|
34
|
+
assert normpath("///") == "/"
|
|
35
|
+
|
|
36
|
+
def test_remove_single_dot(self):
|
|
37
|
+
assert normpath("/./") == "/"
|
|
38
|
+
|
|
39
|
+
def test_double_dot_interpreted(self):
|
|
40
|
+
assert normpath("/../") == "/"
|
|
41
|
+
assert normpath("/foo/../") == "/"
|
|
42
|
+
|
|
43
|
+
def test_triple_dot_preserved(self):
|
|
44
|
+
assert normpath("/.../") == "/.../"
|
|
45
|
+
|
|
46
|
+
def test_combined_patterns(self):
|
|
47
|
+
assert normpath("/..//../") == "/"
|
|
48
|
+
assert normpath("/hello/.//dolly//") == "/hello/dolly/"
|
|
49
|
+
assert normpath("///hello/.//dolly//./..//.//sailor") == "/hello/sailor"
|
|
50
|
+
|
|
51
|
+
def test_trailing_slash_preserved(self):
|
|
52
|
+
assert normpath("/sliced/bread/") == "/sliced/bread/"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestMakeQuery(object):
|
|
56
|
+
def test_make_query(self):
|
|
57
|
+
assert sorted(make_query(a="1", b=2).split("&")) == ["a=1", "b=2"]
|
|
58
|
+
assert make_query(a="one two three") == "a=one+two+three"
|
|
59
|
+
assert make_query(a=["one", "two", "three"]) == "a=one&a=two&a=three"
|
|
60
|
+
|
|
61
|
+
def test_make_query_unicode(self):
|
|
62
|
+
assert (
|
|
63
|
+
make_query(a=[alpha, beta, gamma], charset="utf8")
|
|
64
|
+
== "a=%CE%B1&a=%CE%B2&a=%CE%B3"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def test_make_query_unicode_default_encoding(self):
|
|
68
|
+
assert make_query(a=[alpha, beta, gamma], charset="utf8") == make_query(
|
|
69
|
+
a=[alpha, beta, gamma]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestSafeURL(object):
|
|
74
|
+
def test_it_loads_defaults_from_context(self):
|
|
75
|
+
with FrescoApp().requestcontext(
|
|
76
|
+
**{"wsgi.url_scheme": "https", "HTTP_HOST": "foo.example.org"}
|
|
77
|
+
):
|
|
78
|
+
assert is_safe_url("http://foo.example.org/") is True
|
|
79
|
+
assert is_safe_url("https://foo.example.org/") is True
|
|
80
|
+
assert is_safe_url("//foo.example.org/") is True
|
|
81
|
+
assert is_safe_url("http://example.org/") is False
|
|
82
|
+
assert is_safe_url("https://example.org/") is False
|
|
83
|
+
assert is_safe_url("//example.org/") is False
|
|
84
|
+
|
|
85
|
+
def test_it_passes_safe_urls(self):
|
|
86
|
+
safe = [
|
|
87
|
+
"/foo",
|
|
88
|
+
"http://good.example.org/x",
|
|
89
|
+
"https://good.example.org/",
|
|
90
|
+
"HTTPS://good.example.org/",
|
|
91
|
+
"//good.example.org/",
|
|
92
|
+
"/path?next_url=http://bad.example.org/",
|
|
93
|
+
]
|
|
94
|
+
for u in safe:
|
|
95
|
+
assert is_safe_url(u, allowed_hosts={"good.example.org"}) is True
|
|
96
|
+
|
|
97
|
+
def test_it_handles_port_numbers_correctly(self):
|
|
98
|
+
def test(scheme, server_name="localhost", server_port="80", http_host=None):
|
|
99
|
+
env = {
|
|
100
|
+
"SERVER_NAME": server_name,
|
|
101
|
+
"SERVER_PORT": server_port,
|
|
102
|
+
"wsgi.url_scheme": scheme,
|
|
103
|
+
"HTTP_HOST": http_host,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def test(url, expected):
|
|
107
|
+
with FrescoApp().requestcontext(**env):
|
|
108
|
+
assert is_safe_url(url) is expected
|
|
109
|
+
|
|
110
|
+
return test
|
|
111
|
+
|
|
112
|
+
# server_name, server_port set, but no http_host
|
|
113
|
+
test("http", "example", "80")("http://example/", True)
|
|
114
|
+
test("http", "example", "80")("http://example:80/", True)
|
|
115
|
+
test("http", "example", "80")("https://example/", True)
|
|
116
|
+
test("http", "example", "80")("https://example:443/", True)
|
|
117
|
+
test("http", "example", "80")("http://example:443/", False)
|
|
118
|
+
test("http", "example", "80")("http://example:8080/", False)
|
|
119
|
+
|
|
120
|
+
test("https", "example", "443")("http://example/", True)
|
|
121
|
+
test("https", "example", "443")("http://example:80/", True)
|
|
122
|
+
test("https", "example", "443")("https://example/", True)
|
|
123
|
+
test("https", "example", "443")("https://example:443/", True)
|
|
124
|
+
test("https", "example", "443")("https://example:80/", False)
|
|
125
|
+
|
|
126
|
+
test("http", "example", "8080")("http://example:8080/", True)
|
|
127
|
+
test("http", "example", "8080")("https://example:8080/", True)
|
|
128
|
+
test("http", "example", "8080")("http://example/", False)
|
|
129
|
+
test("http", "example", "8080")("http://example:80/", False)
|
|
130
|
+
|
|
131
|
+
# http_host set - strict match on origin
|
|
132
|
+
test("http", http_host="example")("http://example/", True)
|
|
133
|
+
test("http", http_host="example")("http://example:80/", True)
|
|
134
|
+
test("http", http_host="example")("https://example/", True)
|
|
135
|
+
test("http", http_host="example")("https://example:443/", True)
|
|
136
|
+
test("http", http_host="example")("http://example:443/", False)
|
|
137
|
+
test("http", http_host="example")("http://example:8080/", False)
|
|
138
|
+
|
|
139
|
+
test("https", http_host="example")("http://example/", True)
|
|
140
|
+
test("https", http_host="example")("http://example:80/", True)
|
|
141
|
+
test("https", http_host="example")("https://example/", True)
|
|
142
|
+
test("https", http_host="example")("https://example:443/", True)
|
|
143
|
+
test("https", http_host="example")("http://example:443/", False)
|
|
144
|
+
test("https", http_host="example")("http://example:8080/", False)
|
|
145
|
+
|
|
146
|
+
test("http", http_host="example:80")("http://example/", True)
|
|
147
|
+
test("http", http_host="example:80")("http://example:80/", True)
|
|
148
|
+
test("http", http_host="example:80")("https://example/", True)
|
|
149
|
+
test("http", http_host="example:80")("https://example:443/", True)
|
|
150
|
+
test("http", http_host="example:80")("http://example:443/", False)
|
|
151
|
+
test("http", http_host="example:80")("http://example:8080/", False)
|
|
152
|
+
|
|
153
|
+
test("https", http_host="example:443")("http://example/", True)
|
|
154
|
+
test("https", http_host="example:443")("http://example:80/", True)
|
|
155
|
+
test("https", http_host="example:443")("https://example/", True)
|
|
156
|
+
test("https", http_host="example:443")("https://example:443/", True)
|
|
157
|
+
test("https", http_host="example:443")("http://example:443/", False)
|
|
158
|
+
test("https", http_host="example:443")("http://example:8080/", False)
|
|
159
|
+
|
|
160
|
+
test("http", http_host="example:8080")("http://example:8080/", True)
|
|
161
|
+
test("http", http_host="example:8080")("https://example:8080/", True)
|
|
162
|
+
test("http", http_host="example:8080")("http://example/", False)
|
|
163
|
+
test("http", http_host="example:8080")("http://example:80/", False)
|
|
164
|
+
|
|
165
|
+
def test_it_catches_unsafe_urls(self):
|
|
166
|
+
unsafe = [
|
|
167
|
+
"///foo",
|
|
168
|
+
"http://good.example.org@bad.example.org/",
|
|
169
|
+
"http://good.example.org:good.example.org@bad.example.org/",
|
|
170
|
+
"javascript:do_something_bad()",
|
|
171
|
+
"",
|
|
172
|
+
"\x00http://good.example.org",
|
|
173
|
+
"http://good.example.org\nHost: http://bad.example.org",
|
|
174
|
+
]
|
|
175
|
+
for u in unsafe:
|
|
176
|
+
assert is_safe_url(u, allowed_hosts={"good.example.org"}) is False
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# encoding=UTF-8
|
|
2
|
+
# Copyright 2015 Oliver Cope
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
#
|
|
16
|
+
from typing import Dict
|
|
17
|
+
|
|
18
|
+
from fresco import FrescoApp, Response
|
|
19
|
+
from fresco.util.wsgi import (
|
|
20
|
+
ClosingIterator,
|
|
21
|
+
StartResponseWrapper,
|
|
22
|
+
getenv,
|
|
23
|
+
setenv,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Counter(object):
|
|
28
|
+
value = 0
|
|
29
|
+
|
|
30
|
+
def inc(self):
|
|
31
|
+
self.value += 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def start_response(status, headers, exc_info=None):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _TestException(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestClosingIterator(object):
|
|
43
|
+
def app(self, environ, start_response):
|
|
44
|
+
start_response("200 OK", [("Content-Type: text/plain")])
|
|
45
|
+
yield "Foo"
|
|
46
|
+
yield "Bar"
|
|
47
|
+
|
|
48
|
+
def test_close_called_after_iterator_finished(self):
|
|
49
|
+
count = Counter()
|
|
50
|
+
environ: Dict[str, str] = {}
|
|
51
|
+
|
|
52
|
+
result = self.app(environ, start_response)
|
|
53
|
+
result = ClosingIterator(result, count.inc)
|
|
54
|
+
assert count.value == 0
|
|
55
|
+
try:
|
|
56
|
+
list(result)
|
|
57
|
+
finally:
|
|
58
|
+
result.close()
|
|
59
|
+
assert count.value == 1
|
|
60
|
+
|
|
61
|
+
def test_multiple_close_functions_called(self):
|
|
62
|
+
count1 = Counter()
|
|
63
|
+
count2 = Counter()
|
|
64
|
+
environ: Dict[str, str] = {}
|
|
65
|
+
|
|
66
|
+
result = self.app(environ, start_response)
|
|
67
|
+
result = ClosingIterator(result, count1.inc, count2.inc)
|
|
68
|
+
assert count1.value == 0
|
|
69
|
+
assert count2.value == 0
|
|
70
|
+
try:
|
|
71
|
+
list(result)
|
|
72
|
+
finally:
|
|
73
|
+
result.close()
|
|
74
|
+
assert count1.value == 1
|
|
75
|
+
assert count2.value == 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestStartResponseWrapper(object):
|
|
79
|
+
def test_write(self):
|
|
80
|
+
def wsgiapp(environ, start_response):
|
|
81
|
+
start_response = StartResponseWrapper(start_response)
|
|
82
|
+
write = start_response("200 OK", [("Content-Type", "text/plain")])
|
|
83
|
+
|
|
84
|
+
write(b"cat")
|
|
85
|
+
write(b"sat")
|
|
86
|
+
write2 = start_response.call_start_response()
|
|
87
|
+
write2(b"mat")
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
with FrescoApp().requestcontext() as c:
|
|
91
|
+
r = Response.from_wsgi(
|
|
92
|
+
wsgiapp, c.request.environ, lambda status, headers: None
|
|
93
|
+
)
|
|
94
|
+
assert b"".join(r.content) == b"catsatmat"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestEnvironGetSet(object):
|
|
98
|
+
wsgibytes = "ø".encode("UTF-8")
|
|
99
|
+
wsgistr = wsgibytes.decode("ISO-8859-1")
|
|
100
|
+
|
|
101
|
+
def test_getenv_returns_bytes(self):
|
|
102
|
+
assert getenv({"x": self.wsgistr}, "x") == "ø".encode("UTF-8")
|
|
103
|
+
|
|
104
|
+
def test_setenv_sets_str(self):
|
|
105
|
+
env: Dict[str, str] = {}
|
|
106
|
+
setenv(env, "x", "ø".encode("UTF-8"))
|
|
107
|
+
assert env["x"] == self.wsgistr
|
fresco/util/contentencodings.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from operator import methodcaller
|
|
2
2
|
import itertools
|
|
3
3
|
import functools
|
|
4
|
+
import typing
|
|
4
5
|
|
|
5
6
|
#: Allowed character encodings.
|
|
6
7
|
#: This list is based on the list of standard encodings here:
|
|
@@ -312,7 +313,7 @@ ALLOWED_ENCODINGS = {
|
|
|
312
313
|
# Spelling alternatives that only differ in case or use a hyphen
|
|
313
314
|
# instead of an underscore are valid aliases; for example, 'utf-8' is an
|
|
314
315
|
# alias for the 'utf_8' codec."
|
|
315
|
-
transforms = [
|
|
316
|
+
transforms: list[list[typing.Callable]] = [
|
|
316
317
|
[
|
|
317
318
|
lambda s: s,
|
|
318
319
|
methodcaller("lower"),
|
fresco/util/http.py
CHANGED
|
@@ -454,12 +454,14 @@ def parse_multipart(
|
|
|
454
454
|
f.close()
|
|
455
455
|
raise
|
|
456
456
|
|
|
457
|
-
close: Optional[Callable]
|
|
457
|
+
close: Optional[Callable]
|
|
458
458
|
if open_files:
|
|
459
459
|
|
|
460
460
|
def close():
|
|
461
461
|
for f in open_files:
|
|
462
462
|
f.close()
|
|
463
|
+
else:
|
|
464
|
+
close = None
|
|
463
465
|
|
|
464
466
|
return fields, close
|
|
465
467
|
|
fresco/util/wsgi.py
CHANGED
|
@@ -310,7 +310,7 @@ class ClosingIterator(object):
|
|
|
310
310
|
func()
|
|
311
311
|
except Exception:
|
|
312
312
|
logger.exception(
|
|
313
|
-
"ContentIterator close function {func!r} raised an exception"
|
|
313
|
+
f"ContentIterator close function {func!r} raised an exception"
|
|
314
314
|
)
|
|
315
315
|
self._closed = True
|
|
316
316
|
|