py2http 0.1.61__tar.gz → 0.1.62__tar.gz
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.
- {py2http-0.1.61 → py2http-0.1.62}/PKG-INFO +1 -1
- {py2http-0.1.61 → py2http-0.1.62}/py2http/decorators.py +62 -8
- py2http-0.1.62/py2http/tests/test_binary_req.py +114 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.61 → py2http-0.1.62}/py2http.egg-info/SOURCES.txt +1 -0
- {py2http-0.1.61 → py2http-0.1.62}/setup.cfg +1 -1
- {py2http-0.1.61 → py2http-0.1.62}/LICENSE +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/README.md +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/__init__.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/bottle_plugins.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/config.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/constants.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/default_configs.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/diagnosis.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/examples/object_dispatch.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/middleware.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/route_utils.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/schema_tools.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/service.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/tests/utils_for_testing.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/types.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http/util.py +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.61 → py2http-0.1.62}/setup.py +0 -0
|
@@ -10,7 +10,8 @@ from inspect import (
|
|
|
10
10
|
import inspect
|
|
11
11
|
import json
|
|
12
12
|
import pickle
|
|
13
|
-
|
|
13
|
+
import warnings
|
|
14
|
+
from typing import Iterable, Callable, Union, Mapping, Optional
|
|
14
15
|
from functools import lru_cache, partial, wraps, update_wrapper
|
|
15
16
|
from json import JSONEncoder, dumps
|
|
16
17
|
from aiohttp import web
|
|
@@ -1007,7 +1008,7 @@ def _validate_and_invoke_mapper(func, inputs):
|
|
|
1007
1008
|
return func(**inputs)
|
|
1008
1009
|
|
|
1009
1010
|
|
|
1010
|
-
def _handle_req(func, content_type):
|
|
1011
|
+
def _handle_req(func, content_type, *, binary_loads=None):
|
|
1011
1012
|
func.request_schema = mk_input_schema_from_func(func)
|
|
1012
1013
|
func.content_type = content_type
|
|
1013
1014
|
|
|
@@ -1017,7 +1018,7 @@ def _handle_req(func, content_type):
|
|
|
1017
1018
|
if content_type not in req.content_type:
|
|
1018
1019
|
raise RuntimeError(f"The incoming request's content is of type \
|
|
1019
1020
|
{req.content_type}, when {content_type} is expected.")
|
|
1020
|
-
inputs = _get_inputs_from_request(req, content_type)
|
|
1021
|
+
inputs = _get_inputs_from_request(req, content_type, binary_loads=binary_loads)
|
|
1021
1022
|
return _validate_and_invoke_mapper(func, inputs)
|
|
1022
1023
|
|
|
1023
1024
|
return input_mapper
|
|
@@ -1027,8 +1028,53 @@ def handle_json_req(func):
|
|
|
1027
1028
|
return _handle_req(func, JSON_CONTENT_TYPE)
|
|
1028
1029
|
|
|
1029
1030
|
|
|
1030
|
-
def
|
|
1031
|
-
|
|
1031
|
+
def unsafe_pickle_loads(data: bytes):
|
|
1032
|
+
"""Unpickle ``data``. UNSAFE on anything a client can send.
|
|
1033
|
+
|
|
1034
|
+
Unpickling runs code chosen by whoever produced the bytes, so this must only
|
|
1035
|
+
be used when every caller of the endpoint is fully trusted (for example, a
|
|
1036
|
+
service reachable only by your own processes). It exists so that opting into
|
|
1037
|
+
pickled request bodies is explicit and visible at the call site::
|
|
1038
|
+
|
|
1039
|
+
handle_binary_req(func, loads=unsafe_pickle_loads)
|
|
1040
|
+
"""
|
|
1041
|
+
return pickle.loads(data)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def handle_binary_req(func, *, loads: Optional[Callable[[bytes], Mapping]] = None):
|
|
1045
|
+
"""Make an input mapper that decodes a binary (octet-stream) request body.
|
|
1046
|
+
|
|
1047
|
+
``loads`` turns the raw body bytes into the mapping of keyword arguments for
|
|
1048
|
+
``func``. There is deliberately no default: request bodies used to be
|
|
1049
|
+
unpickled implicitly, which lets any client run code on the server. Pass a
|
|
1050
|
+
safe decoder of your own, or ``loads=unsafe_pickle_loads`` if (and only if)
|
|
1051
|
+
every client is trusted.
|
|
1052
|
+
|
|
1053
|
+
Note that ``http2py`` clients encode binary request bodies with pickle, so
|
|
1054
|
+
they only work against endpoints that opted into ``unsafe_pickle_loads``.
|
|
1055
|
+
|
|
1056
|
+
>>> handle_binary_req(lambda x: x) # doctest: +ELLIPSIS
|
|
1057
|
+
Traceback (most recent call last):
|
|
1058
|
+
...
|
|
1059
|
+
TypeError: handle_binary_req needs an explicit loads=... (bytes -> dict of inputs). ...
|
|
1060
|
+
"""
|
|
1061
|
+
if loads is None:
|
|
1062
|
+
raise TypeError(
|
|
1063
|
+
"handle_binary_req needs an explicit loads=... (bytes -> dict of "
|
|
1064
|
+
"inputs). Request bodies are no longer unpickled by default, since "
|
|
1065
|
+
"unpickling client data lets the client run code on the server. "
|
|
1066
|
+
"Use loads=unsafe_pickle_loads only if every client is trusted."
|
|
1067
|
+
)
|
|
1068
|
+
if not callable(loads):
|
|
1069
|
+
raise TypeError(f"loads must be callable, got {loads!r}")
|
|
1070
|
+
if loads is pickle.loads:
|
|
1071
|
+
warnings.warn(
|
|
1072
|
+
"handle_binary_req(loads=pickle.loads) unpickles client data, which lets "
|
|
1073
|
+
"clients run code on the server. Use loads=unsafe_pickle_loads to make "
|
|
1074
|
+
"that choice explicit, and only if every client is trusted.",
|
|
1075
|
+
stacklevel=2,
|
|
1076
|
+
)
|
|
1077
|
+
return _handle_req(func, BINARY_CONTENT_TYPE, binary_loads=loads)
|
|
1032
1078
|
|
|
1033
1079
|
|
|
1034
1080
|
def handle_form_req(func):
|
|
@@ -1170,7 +1216,7 @@ def mk_input_mapper(input_map):
|
|
|
1170
1216
|
return decorator
|
|
1171
1217
|
|
|
1172
1218
|
|
|
1173
|
-
def _get_inputs_from_request(request, content_type):
|
|
1219
|
+
def _get_inputs_from_request(request, content_type, *, binary_loads=None):
|
|
1174
1220
|
defaults = getattr(request, "defaults", {})
|
|
1175
1221
|
if request.method == "POST":
|
|
1176
1222
|
if content_type == JSON_CONTENT_TYPE:
|
|
@@ -1179,8 +1225,16 @@ def _get_inputs_from_request(request, content_type):
|
|
|
1179
1225
|
data = request.body.read().decode("utf-8")
|
|
1180
1226
|
inputs = json.loads(data)
|
|
1181
1227
|
elif content_type == BINARY_CONTENT_TYPE:
|
|
1182
|
-
|
|
1183
|
-
|
|
1228
|
+
if binary_loads is None:
|
|
1229
|
+
raise TypeError(
|
|
1230
|
+
"No decoder given for a binary request body; see handle_binary_req."
|
|
1231
|
+
)
|
|
1232
|
+
inputs = binary_loads(request.body.read())
|
|
1233
|
+
if not isinstance(inputs, Mapping):
|
|
1234
|
+
raise TypeError(
|
|
1235
|
+
f"The binary request body decoded to a {type(inputs).__name__}, "
|
|
1236
|
+
"not a mapping of inputs."
|
|
1237
|
+
)
|
|
1184
1238
|
elif content_type == FORM_CONTENT_TYPE:
|
|
1185
1239
|
fields = json.loads(
|
|
1186
1240
|
request.files.pop("__fields").file.read().decode("utf-8")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Binary (octet-stream) request bodies are never unpickled unless explicitly asked."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import pickle
|
|
6
|
+
from wsgiref.util import setup_testing_defaults
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from py2http import mk_app
|
|
11
|
+
from py2http.constants import BINARY_CONTENT_TYPE, JSON_CONTENT_TYPE
|
|
12
|
+
from py2http.decorators import (
|
|
13
|
+
_get_inputs_from_request,
|
|
14
|
+
handle_binary_req,
|
|
15
|
+
unsafe_pickle_loads,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_calls = []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _record(*args):
|
|
22
|
+
_calls.append(args)
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _RecordsWhenUnpickled:
|
|
27
|
+
"""Unpickling this calls ``_record``: a harmless stand-in for side effects."""
|
|
28
|
+
|
|
29
|
+
def __reduce__(self):
|
|
30
|
+
return (_record, ("unpickled",))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def calls():
|
|
35
|
+
_calls.clear()
|
|
36
|
+
yield _calls
|
|
37
|
+
_calls.clear()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _FakeRequest:
|
|
41
|
+
def __init__(self, body: bytes, *, content_type=BINARY_CONTENT_TYPE):
|
|
42
|
+
self.method = "POST"
|
|
43
|
+
self.content_type = content_type
|
|
44
|
+
self.body = io.BytesIO(body)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add(x: int, y: int = 1):
|
|
48
|
+
return x + y
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _post(app, path, body: bytes, content_type: str):
|
|
52
|
+
"""Call a WSGI app directly; return (status, body)."""
|
|
53
|
+
environ = {}
|
|
54
|
+
setup_testing_defaults(environ)
|
|
55
|
+
environ.update(
|
|
56
|
+
REQUEST_METHOD="POST",
|
|
57
|
+
PATH_INFO=path,
|
|
58
|
+
CONTENT_TYPE=content_type,
|
|
59
|
+
CONTENT_LENGTH=str(len(body)),
|
|
60
|
+
)
|
|
61
|
+
environ["wsgi.input"] = io.BytesIO(body)
|
|
62
|
+
statuses = []
|
|
63
|
+
out = b"".join(app(environ, lambda status, headers, *a: statuses.append(status)))
|
|
64
|
+
return statuses[0], out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_binary_req_requires_explicit_loads():
|
|
68
|
+
with pytest.raises(TypeError, match="explicit loads"):
|
|
69
|
+
handle_binary_req(add)
|
|
70
|
+
with pytest.raises(TypeError, match="callable"):
|
|
71
|
+
handle_binary_req(add, loads="pickle")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_bare_pickle_loads_warns():
|
|
75
|
+
with pytest.warns(UserWarning, match="unsafe_pickle_loads"):
|
|
76
|
+
handle_binary_req(add, loads=pickle.loads)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_binary_body_not_unpickled_by_default(calls):
|
|
80
|
+
payload = pickle.dumps(_RecordsWhenUnpickled())
|
|
81
|
+
with pytest.raises(TypeError, match="No decoder"):
|
|
82
|
+
_get_inputs_from_request(_FakeRequest(payload), BINARY_CONTENT_TYPE)
|
|
83
|
+
assert calls == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_binary_req_with_safe_loads(calls):
|
|
87
|
+
mapper = handle_binary_req(add, loads=json.loads)
|
|
88
|
+
assert mapper(_FakeRequest(json.dumps({"x": 2, "y": 3}).encode())) == 5
|
|
89
|
+
with pytest.raises(ValueError):
|
|
90
|
+
mapper(_FakeRequest(pickle.dumps(_RecordsWhenUnpickled())))
|
|
91
|
+
assert calls == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_binary_req_rejects_non_mapping():
|
|
95
|
+
mapper = handle_binary_req(add, loads=json.loads)
|
|
96
|
+
with pytest.raises(TypeError, match="not a mapping"):
|
|
97
|
+
mapper(_FakeRequest(b"[1, 2]"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_binary_req_explicit_unsafe_opt_in_still_works():
|
|
101
|
+
mapper = handle_binary_req(add, loads=unsafe_pickle_loads)
|
|
102
|
+
assert mapper(_FakeRequest(pickle.dumps({"x": 2}))) == 3
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_default_service_does_not_unpickle_octet_stream_bodies(calls):
|
|
106
|
+
app = mk_app([add])
|
|
107
|
+
status, _ = _post(
|
|
108
|
+
app, "/add", pickle.dumps(_RecordsWhenUnpickled()), BINARY_CONTENT_TYPE
|
|
109
|
+
)
|
|
110
|
+
assert not status.startswith("2")
|
|
111
|
+
assert calls == []
|
|
112
|
+
# the normal JSON path still works
|
|
113
|
+
status, body = _post(app, "/add", json.dumps({"x": 2}).encode(), JSON_CONTENT_TYPE)
|
|
114
|
+
assert status.startswith("200") and json.loads(body) == 3
|
|
@@ -33,6 +33,7 @@ py2http/tests/__init__.py
|
|
|
33
33
|
py2http/tests/example_service_test.py
|
|
34
34
|
py2http/tests/objects_for_testing.py
|
|
35
35
|
py2http/tests/simple_run_process_test.py
|
|
36
|
+
py2http/tests/test_binary_req.py
|
|
36
37
|
py2http/tests/test_decorators.py
|
|
37
38
|
py2http/tests/test_flask.py
|
|
38
39
|
py2http/tests/test_not_set_defaults.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|