diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d3bcec57..e2f2e240c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +* Stricter type checking on values in `data=...`, ensure only primitive values that neatly map to `str` are allowed. (#3128) * Fix `app` type signature in `ASGITransport`. (#3109) ## 0.27.0 (21st February, 2024) diff --git a/httpx/_content.py b/httpx/_content.py index 786699f38f..bd45e3e966 100644 --- a/httpx/_content.py +++ b/httpx/_content.py @@ -10,6 +10,7 @@ Iterable, Iterator, Mapping, + Tuple, ) from urllib.parse import urlencode @@ -17,13 +18,14 @@ from ._multipart import MultipartStream from ._types import ( AsyncByteStream, + PrimitiveData, RequestContent, RequestData, RequestFiles, ResponseContent, SyncByteStream, ) -from ._utils import peek_filelike_length, primitive_value_to_str +from ._utils import peek_filelike_length __all__ = ["ByteStream"] @@ -133,15 +135,36 @@ def encode_content( raise TypeError(f"Unexpected type for 'content', {type(content)!r}") +def _coerce_type(key: str, value: PrimitiveData) -> Tuple[str, str]: + if not isinstance(key, str): + raise TypeError( + f"Request data keys must be str. " f"Got type {type(key).__name__!r}." + ) + + if value is True: + return (key, "true") + elif value is False: + return (key, "false") + elif value is None: + return (key, "") + elif isinstance(value, (str, int, float)): + return (key, str(value)) + + raise TypeError( + f"Request data values must be str, int, float, bool, or None. " + f"Got type {type(value).__name__!r} for key {key!r}." + ) + + def encode_urlencoded_data( data: RequestData, ) -> tuple[dict[str, str], ByteStream]: plain_data = [] for key, value in data.items(): if isinstance(value, (list, tuple)): - plain_data.extend([(key, primitive_value_to_str(item)) for item in value]) + plain_data.extend([_coerce_type(key, each) for each in value]) else: - plain_data.append((key, primitive_value_to_str(value))) + plain_data.append(_coerce_type(key, value)) body = urlencode(plain_data, doseq=True).encode("utf-8") content_length = str(len(body)) content_type = "application/x-www-form-urlencoded" diff --git a/tests/test_content.py b/tests/test_content.py index 21c92dd799..9c3a72b376 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -200,7 +200,7 @@ async def test_urlencoded_content(): @pytest.mark.anyio async def test_urlencoded_boolean(): - request = httpx.Request(method, url, data={"example": True}) + request = httpx.Request(method, url, data={"true": True, "false": False}) assert isinstance(request.stream, typing.Iterable) assert isinstance(request.stream, typing.AsyncIterable) @@ -209,11 +209,11 @@ async def test_urlencoded_boolean(): assert request.headers == { "Host": "www.example.com", - "Content-Length": "12", + "Content-Length": "21", "Content-Type": "application/x-www-form-urlencoded", } - assert sync_content == b"example=true" - assert async_content == b"example=true" + assert sync_content == b"true=true&false=false" + assert async_content == b"true=true&false=false" @pytest.mark.anyio @@ -252,6 +252,21 @@ async def test_urlencoded_list(): assert async_content == b"example=a&example=1&example=true" +def test_urlencoded_invalid_key(): + with pytest.raises(TypeError) as e: + httpx.Request(method, url, data={123: "value"}) # type: ignore + assert str(e.value) == "Request data keys must be str. Got type 'int'." + + +def test_urlencoded_invalid_value(): + with pytest.raises(TypeError) as e: + httpx.Request(method, url, data={"key": {"this": "ain't json, buddy"}}) + assert str(e.value) == ( + "Request data values must be str, int, float, bool, or None. " + "Got type 'dict' for key 'key'." + ) + + @pytest.mark.anyio async def test_multipart_files_content(): files = {"file": io.BytesIO(b"")}