Skip to content

Commit fbcad60

Browse files
Merge pull request #3505 from VWS-Python/adamtheturtle/issue-3392
Respond to non-UTF-8 and truncated multipart bodies (#3392)
2 parents 01579f1 + a2b6adf commit fbcad60

12 files changed

Lines changed: 406 additions & 68 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
- tests/mock_vws/test_query.py::TestTargetStatusFailed
4747
- tests/mock_vws/test_query.py::TestDateFormats
4848
- tests/mock_vws/test_query.py::TestInactiveProject
49+
- tests/mock_vws/test_query.py::TestTruncatedBody
4950
- tests/mock_vws/test_add_target.py::TestContentTypes
5051
- tests/mock_vws/test_add_target.py::TestMissingData
5152
- tests/mock_vws/test_add_target.py::TestWidth

newsfragments/3392.change

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Respond, rather than failing to respond, to a request body which is not UTF-8 and to a query request whose ``multipart/form-data`` body ends before its closing boundary.

src/mock_vws/_query_tools.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
"""Tools for making Vuforia queries."""
22

33
import base64
4-
import io
54
import uuid
65
from collections.abc import Iterable, Mapping
7-
from email.message import EmailMessage
86
from typing import Any
97

108
from beartype import beartype
11-
from werkzeug.formparser import MultiPartParser
129

1310
from mock_vws._base64_decoding import decode_base64
1411
from mock_vws._constants import ResultCodes, TargetStatuses
1512
from mock_vws._database_matchers import get_database_matching_client_keys
1613
from mock_vws._matching import matching_targets
1714
from mock_vws._mock_common import json_dump
15+
from mock_vws._query_validators.multipart import parse_multipart
1816
from mock_vws.database import CloudDatabase
1917
from mock_vws.image_matchers import ImageMatcher
2018

@@ -42,15 +40,9 @@ def get_query_match_response_text(
4240
Returns:
4341
The response text for a query endpoint request.
4442
"""
45-
email_message = EmailMessage()
46-
email_message["Content-Type"] = request_headers["Content-Type"]
47-
boundary = email_message.get_boundary(failobj="")
48-
49-
parser = MultiPartParser()
50-
fields, files = parser.parse(
51-
stream=io.BytesIO(initial_bytes=request_body),
52-
boundary=boundary.encode(encoding="utf-8"),
53-
content_length=len(request_body),
43+
fields, files = parse_multipart(
44+
request_headers=request_headers,
45+
request_body=request_body,
5446
)
5547

5648
max_num_results = fields.get(key="max_num_results", default="1")

src/mock_vws/_query_validators/exceptions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,29 @@ def __init__(self) -> None:
201201
}
202202

203203

204+
@beartype
205+
class NoContentDispositionError(ValidatorError):
206+
"""Exception raised when a part of a multipart body has no
207+
``Content-Disposition`` header.
208+
"""
209+
210+
def __init__(self) -> None:
211+
"""Initialize a missing ``Content-Disposition`` header
212+
response.
213+
"""
214+
super().__init__()
215+
self.status_code = HTTPStatus.BAD_REQUEST
216+
self.response_text = (
217+
"Could find no Content-Disposition header within part"
218+
)
219+
self.headers = {
220+
**_BASE_HEADERS,
221+
"Content-Type": "text/plain;charset=utf-8",
222+
"Date": http_date(),
223+
"Content-Length": str(object=len(self.response_text)),
224+
}
225+
226+
204227
@beartype
205228
class AuthHeaderMissingError(ValidatorError):
206229
"""Exception raised when an auth header is not given."""

src/mock_vws/_query_validators/fields_validators.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Validators for the fields given."""
22

3-
import io
43
import logging
54
from collections.abc import Mapping
6-
from email.message import EmailMessage
75

86
from beartype import beartype
9-
from werkzeug.formparser import MultiPartParser
107

118
from mock_vws._query_validators.exceptions import UnknownParametersError
9+
from mock_vws._query_validators.multipart import parse_multipart
1210

1311
_LOGGER = logging.getLogger(name=__name__)
1412

@@ -27,15 +25,12 @@ def validate_extra_fields(
2725
2826
Raises:
2927
UnknownParametersError: Extra fields are given.
28+
NoContentDispositionError: A part of the body has no
29+
``Content-Disposition`` header.
3030
"""
31-
email_message = EmailMessage()
32-
email_message["Content-Type"] = request_headers["Content-Type"]
33-
boundary = email_message.get_boundary(failobj="")
34-
parser = MultiPartParser()
35-
fields, files = parser.parse(
36-
stream=io.BytesIO(initial_bytes=request_body),
37-
boundary=boundary.encode(encoding="utf-8"),
38-
content_length=len(request_body),
31+
fields, files = parse_multipart(
32+
request_headers=request_headers,
33+
request_body=request_body,
3934
)
4035
parsed_keys = fields.keys() | files.keys()
4136
known_parameters = {"image", "max_num_results", "include_target_data"}

src/mock_vws/_query_validators/image_validators.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
import io
44
import logging
55
from collections.abc import Mapping
6-
from email.message import EmailMessage
76

87
from beartype import beartype
98
from werkzeug.datastructures import FileStorage, MultiDict
10-
from werkzeug.formparser import MultiPartParser
119

1210
from mock_vws._image_opening import open_image
1311
from mock_vws._query_validators.exceptions import (
1412
BadImageError,
1513
ImageNotGivenError,
1614
RequestEntityTooLargeError,
1715
)
16+
from mock_vws._query_validators.multipart import parse_multipart
1817

1918
_LOGGER = logging.getLogger(name=__name__)
2019

@@ -34,14 +33,9 @@ def _parse_multipart_files(
3433
Returns:
3534
The files parsed from the multipart body.
3635
"""
37-
email_message = EmailMessage()
38-
email_message["Content-Type"] = request_headers["Content-Type"]
39-
boundary = email_message.get_boundary(failobj="")
40-
parser = MultiPartParser()
41-
_, files = parser.parse(
42-
stream=io.BytesIO(initial_bytes=request_body),
43-
boundary=boundary.encode(encoding="utf-8"),
44-
content_length=len(request_body),
36+
_, files = parse_multipart(
37+
request_headers=request_headers,
38+
request_body=request_body,
4539
)
4640
return files
4741

src/mock_vws/_query_validators/include_target_data_validators.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Validators for the ``include_target_data`` field."""
22

3-
import io
43
import logging
54
from collections.abc import Mapping
6-
from email.message import EmailMessage
75

86
from beartype import beartype
9-
from werkzeug.formparser import MultiPartParser
107

118
from mock_vws._query_validators.exceptions import InvalidIncludeTargetDataError
9+
from mock_vws._query_validators.multipart import parse_multipart
1210

1311
_LOGGER = logging.getLogger(name=__name__)
1412

@@ -31,14 +29,9 @@ def validate_include_target_data(
3129
InvalidIncludeTargetDataError: The ``include_target_data`` field is not
3230
an accepted value.
3331
"""
34-
email_message = EmailMessage()
35-
email_message["Content-Type"] = request_headers["Content-Type"]
36-
boundary = email_message.get_boundary(failobj="")
37-
parser = MultiPartParser()
38-
fields, _ = parser.parse(
39-
stream=io.BytesIO(initial_bytes=request_body),
40-
boundary=boundary.encode(encoding="utf-8"),
41-
content_length=len(request_body),
32+
fields, _ = parse_multipart(
33+
request_headers=request_headers,
34+
request_body=request_body,
4235
)
4336
include_target_data = fields.get(key="include_target_data", default="top")
4437
allowed_included_target_data = {"top", "all", "none"}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Parsing of the ``multipart/form-data`` bodies given to the query
2+
API.
3+
"""
4+
5+
import io
6+
import logging
7+
from collections.abc import Mapping
8+
from email.message import EmailMessage
9+
10+
from beartype import beartype
11+
from werkzeug.datastructures import FileStorage, MultiDict
12+
from werkzeug.formparser import MultiPartParser
13+
14+
from mock_vws._query_validators.exceptions import NoContentDispositionError
15+
16+
_LOGGER = logging.getLogger(name=__name__)
17+
18+
19+
@beartype
20+
def _parse_with_boundary(
21+
*,
22+
request_body: bytes,
23+
boundary: bytes,
24+
) -> tuple[MultiDict[str, str], MultiDict[str, FileStorage]]:
25+
"""Parse a multipart body, requiring it to be complete.
26+
27+
Args:
28+
request_body: The body of the request.
29+
boundary: The multipart boundary, without its leading dashes.
30+
31+
Returns:
32+
The fields and the files parsed from the multipart body.
33+
34+
Raises:
35+
ValueError: The body is not a complete multipart body.
36+
"""
37+
parser = MultiPartParser()
38+
return parser.parse(
39+
stream=io.BytesIO(initial_bytes=request_body),
40+
boundary=boundary,
41+
content_length=len(request_body),
42+
)
43+
44+
45+
@beartype
46+
def parse_multipart(
47+
*,
48+
request_headers: Mapping[str, str],
49+
request_body: bytes,
50+
) -> tuple[MultiDict[str, str], MultiDict[str, FileStorage]]:
51+
"""Parse the multipart body of a query request.
52+
53+
Vuforia accepts a body which ends before its closing boundary, as a
54+
client which is cut off mid-upload sends, and treats the end of the body
55+
as the end of the part being uploaded. ``MultiPartParser`` rejects such a
56+
body, so we give it the closing boundary which the client did not send.
57+
58+
Args:
59+
request_headers: The headers sent with the request.
60+
request_body: The body of the request.
61+
62+
Returns:
63+
The fields and the files parsed from the multipart body.
64+
65+
Raises:
66+
NoContentDispositionError: The body ends within the headers of a part,
67+
or a part has no ``Content-Disposition`` header, so no part can be
68+
named.
69+
"""
70+
email_message = EmailMessage()
71+
email_message["Content-Type"] = request_headers["Content-Type"]
72+
boundary = email_message.get_boundary(failobj="").encode(encoding="utf-8")
73+
closing_boundary = b"\r\n--" + boundary + b"--\r\n"
74+
75+
# A body which ends part-way through the closing boundary keeps those
76+
# bytes: Vuforia gives them to the part being uploaded rather than
77+
# ignoring them. We therefore try the body as it was sent before we try
78+
# it without its incomplete closing boundary.
79+
without_partial_boundary = request_body
80+
for length in reversed(range(1, len(closing_boundary))):
81+
if request_body.endswith(closing_boundary[:length]):
82+
without_partial_boundary = request_body[:-length]
83+
break
84+
85+
candidates = (
86+
request_body,
87+
# The body ended within the data of a part.
88+
request_body + closing_boundary,
89+
# The body ended part-way through a header of a part, so the blank
90+
# line which ends the headers of that part is missing too.
91+
request_body + b"\r\n" + closing_boundary,
92+
# The body ended part-way through the closing boundary.
93+
without_partial_boundary + closing_boundary,
94+
)
95+
96+
for candidate in candidates:
97+
try:
98+
return _parse_with_boundary(
99+
request_body=candidate,
100+
boundary=boundary,
101+
)
102+
except ValueError:
103+
continue
104+
105+
# Every remaining body is one in which a part has no usable
106+
# ``Content-Disposition`` header, either because the body ends before that
107+
# header is complete or because the part does not have one.
108+
_LOGGER.warning(msg="A part has no Content-Disposition header.")
109+
raise NoContentDispositionError

src/mock_vws/_query_validators/num_results_validators.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
"""Validators for the ``max_num_results`` fields."""
22

3-
import io
43
import logging
54
from collections.abc import Mapping
6-
from email.message import EmailMessage
75

86
from beartype import beartype
9-
from werkzeug.formparser import MultiPartParser
107

118
from mock_vws._query_validators.exceptions import (
129
InvalidMaxNumResultsError,
1310
MaxNumResultsOutOfRangeError,
1411
)
12+
from mock_vws._query_validators.multipart import parse_multipart
1513

1614
_LOGGER = logging.getLogger(name=__name__)
1715

@@ -36,14 +34,9 @@ def validate_max_num_results(
3634
MaxNumResultsOutOfRangeError: The ``max_num_results`` given is not in
3735
range.
3836
"""
39-
email_message = EmailMessage()
40-
email_message["Content-Type"] = request_headers["Content-Type"]
41-
boundary = email_message.get_boundary(failobj="")
42-
parser = MultiPartParser()
43-
fields, _ = parser.parse(
44-
stream=io.BytesIO(initial_bytes=request_body),
45-
boundary=boundary.encode(encoding="utf-8"),
46-
content_length=len(request_body),
37+
fields, _ = parse_multipart(
38+
request_headers=request_headers,
39+
request_body=request_body,
4740
)
4841
max_num_results = fields.get(key="max_num_results", default="1")
4942

src/mock_vws/_services_validators/json_validators.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,20 @@ def validate_json(*, request_body: bytes, request_path: str) -> None:
5252
request_path: The path of the request.
5353
5454
Raises:
55-
BadRequestError: The request body includes invalid JSON for the
56-
VuMark instance generation endpoint.
57-
FailError: The request body includes invalid JSON for other
58-
endpoints.
55+
BadRequestError: The request body is not valid UTF-8, or includes
56+
invalid JSON, for the VuMark instance generation endpoint.
57+
FailError: The request body is not valid UTF-8, or includes invalid
58+
JSON, for other endpoints.
5959
"""
6060
if not request_body:
6161
return
6262

6363
try:
64-
request_json = json.loads(s=request_body.decode())
65-
except JSONDecodeError as exc:
64+
# Vuforia gives the same response for a body which is not UTF-8, such
65+
# as JSON encoded as latin-1, as it gives for a body which is not
66+
# valid JSON.
67+
request_json = json.loads(s=request_body.decode(encoding="utf-8"))
68+
except (JSONDecodeError, UnicodeDecodeError) as exc:
6669
_LOGGER.warning(msg="The request body is not valid JSON.")
6770
if request_path.endswith("/instances"):
6871
raise BadRequestError from exc

0 commit comments

Comments
 (0)