Skip to content

Commit 42b3dc2

Browse files
authored
Merge pull request #19 from modern-python/chore/input-validation-pass
chore: input-validation pass — Request/Timeout/Limits/ClientConfig __post_init__ guards + charset parser fix
2 parents b64203c + 73697c9 commit 42b3dc2

8 files changed

Lines changed: 226 additions & 7 deletions

File tree

‎planning/deferred-work.md‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ Items raised in reviews that are real but not actionable now.
2727

2828
## Deferred from: code review of story-1-2 (2026-05-13)
2929

30-
- **Charset parser robustness** — quoted whitespace, mismatched quotes, multi-`charset=` directives, substring false-positives (e.g. `boundary` containing `charset=`). (`src/httpware/response.py:20-25`)
31-
- **Header name/value validation** — `with_header` / `with_headers` accept CR/LF (injection), `None`, empty string. CRLF lands with the `Redactor` middleware (Story 5.3); the rest is a generic input-validation pass. (`src/httpware/request.py:21-23,37-39`)
32-
- **URL validation** — `with_url("")` accepts empty; `base_url` has no trailing-slash normalization in config (`AsyncClient._resolve_url` does `base.rstrip('/')` so the practical impact is reduced). (`src/httpware/request.py:25-27`, `src/httpware/config.py:34`)
33-
- **`with_query(None)` handling** — type signature is `Mapping[str, str]`; a runtime `None` slips past typing and breaks downstream iteration with no guard. (`src/httpware/request.py:33-35`)
34-
- **`Timeout` / `Limits` negative-value validation** — no `__post_init__` guard; nonsensical values silently accepted. (`src/httpware/config.py:11-27`)
3530
- **Multi-valued query params** — `Mapping[str, str]` cannot express `?tag=a&tag=b`. Type widening needed. (`src/httpware/request.py:16`)
3631
- **Streaming / async-iterable request bodies** — `body: bytes | None` only. Revisit in streaming work (Story 4.1). (`src/httpware/request.py:18`)
3732
- **`@final` to prevent subclassing** — frozen+slots subclassing is fragile. No current subclasser; defer until needed. (`src/httpware/request.py`, `response.py`, `config.py`)

‎src/httpware/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def _resolve_url(self, path: str) -> str:
119119
base = self._config.base_url
120120
if base is None:
121121
return path
122-
return f"{base.rstrip('/')}/{path.lstrip('/')}"
122+
return f"{base}/{path.lstrip('/')}"
123123

124124
def _build_request(
125125
self,

‎src/httpware/config.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ class Timeout:
1717
write: float = 30.0
1818
pool: float = 5.0
1919

20+
def __post_init__(self) -> None:
21+
for attr in ("connect", "read", "write", "pool"):
22+
value = getattr(self, attr)
23+
if value < 0:
24+
msg = f"Timeout.{attr} must be non-negative (got {value})"
25+
raise ValueError(msg)
26+
2027

2128
@dataclass(frozen=True, slots=True)
2229
class Limits:
@@ -26,6 +33,17 @@ class Limits:
2633
max_keepalive_connections: int = 20
2734
keepalive_expiry: float = 5.0
2835

36+
def __post_init__(self) -> None:
37+
if self.max_connections < 0:
38+
msg = f"max_connections must be non-negative (got {self.max_connections})"
39+
raise ValueError(msg)
40+
if self.max_keepalive_connections < 0:
41+
msg = f"max_keepalive_connections must be non-negative (got {self.max_keepalive_connections})"
42+
raise ValueError(msg)
43+
if self.keepalive_expiry < 0:
44+
msg = f"keepalive_expiry must be non-negative (got {self.keepalive_expiry})"
45+
raise ValueError(msg)
46+
2947

3048
@dataclass(frozen=True, slots=True)
3149
class ClientConfig:
@@ -38,3 +56,14 @@ class ClientConfig:
3856
limits: Limits = field(default_factory=Limits)
3957
decoder: ResponseDecoder = field(default_factory=PydanticDecoder)
4058
middleware: tuple[Middleware, ...] = ()
59+
60+
def __post_init__(self) -> None:
61+
if self.base_url is not None:
62+
if not isinstance(self.base_url, str):
63+
msg = "base_url must be a non-empty string or None"
64+
raise ValueError(msg)
65+
normalized = self.base_url.rstrip("/")
66+
if not normalized:
67+
msg = "base_url must be a non-empty string or None"
68+
raise ValueError(msg)
69+
object.__setattr__(self, "base_url", normalized)

‎src/httpware/request.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@
66
from typing import Any, Self
77

88

9+
def _validate_header_or_cookie(name: str, value: str, *, kind: str) -> None:
10+
if not isinstance(name, str) or not isinstance(value, str):
11+
msg = f"{kind} name and value must be str"
12+
raise TypeError(msg)
13+
if not name or not value:
14+
msg = f"{kind} name and value must be non-empty"
15+
raise ValueError(msg)
16+
if any(c in name or c in value for c in ("\r", "\n")):
17+
msg = f"{kind} name and value must not contain CR or LF"
18+
raise ValueError(msg)
19+
20+
921
@dataclass(frozen=True, slots=True)
1022
class Request:
1123
"""Immutable HTTP request value type."""
@@ -18,6 +30,23 @@ class Request:
1830
body: bytes | None = None
1931
extensions: Mapping[str, Any] = field(default_factory=dict)
2032

33+
def __post_init__(self) -> None:
34+
if not isinstance(self.url, str):
35+
msg = "url must be str"
36+
raise TypeError(msg)
37+
if not self.url:
38+
msg = "url must be non-empty"
39+
raise ValueError(msg)
40+
for field_name in ("headers", "params", "cookies", "extensions"):
41+
field_value = getattr(self, field_name)
42+
if not isinstance(field_value, Mapping):
43+
msg = f"{field_name} must be a Mapping (got {type(field_value).__name__})"
44+
raise TypeError(msg)
45+
for name, value in self.headers.items():
46+
_validate_header_or_cookie(name, value, kind="header")
47+
for name, value in self.cookies.items():
48+
_validate_header_or_cookie(name, value, kind="cookie")
49+
2150
def with_header(self, name: str, value: str) -> Self:
2251
"""Return a copy with the given header added or replaced."""
2352
return dataclasses.replace(self, headers={**self.headers, name: value})

‎src/httpware/response.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def _parse_charset(content_type: str) -> str | None:
2121
for raw in content_type.split(";"):
2222
part = raw.strip()
2323
if part.lower().startswith(_CHARSET_PREFIX):
24-
return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'")
24+
return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'").strip()
2525
return None
2626

2727

‎tests/test_config.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,69 @@ def test_client_config_is_frozen() -> None:
5050
cfg = ClientConfig()
5151
with pytest.raises(FrozenInstanceError):
5252
cfg.base_url = "https://example.com" # ty: ignore[invalid-assignment]
53+
54+
55+
@pytest.mark.parametrize("field", ["connect", "read", "write", "pool"])
56+
def test_timeout_rejects_negative(field: str) -> None:
57+
with pytest.raises(ValueError, match=rf"Timeout\.{field} must be non-negative"):
58+
Timeout(**{field: -1.0})
59+
60+
61+
def test_timeout_accepts_zero() -> None:
62+
# Zero is a valid sentinel (fail immediately on this phase).
63+
Timeout(connect=0.0, read=0.0, write=0.0, pool=0.0)
64+
65+
66+
@pytest.mark.parametrize("field", ["max_connections", "max_keepalive_connections"])
67+
def test_limits_rejects_negative_int(field: str) -> None:
68+
with pytest.raises(ValueError, match=f"{field} must be non-negative"):
69+
Limits(**{field: -1})
70+
71+
72+
def test_limits_rejects_negative_keepalive_expiry() -> None:
73+
with pytest.raises(ValueError, match="keepalive_expiry must be non-negative"):
74+
Limits(keepalive_expiry=-0.5)
75+
76+
77+
def test_limits_accepts_zero() -> None:
78+
Limits(max_connections=0, max_keepalive_connections=0, keepalive_expiry=0.0)
79+
80+
81+
def test_client_config_strips_trailing_slash_from_base_url() -> None:
82+
cfg = ClientConfig(base_url="https://api.example.com/")
83+
assert cfg.base_url == "https://api.example.com"
84+
85+
86+
def test_client_config_leaves_base_url_without_trailing_slash() -> None:
87+
cfg = ClientConfig(base_url="https://api.example.com")
88+
assert cfg.base_url == "https://api.example.com"
89+
90+
91+
def test_client_config_strips_multiple_trailing_slashes() -> None:
92+
cfg = ClientConfig(base_url="https://api.example.com///")
93+
assert cfg.base_url == "https://api.example.com"
94+
95+
96+
def test_client_config_allows_none_base_url() -> None:
97+
cfg = ClientConfig(base_url=None)
98+
assert cfg.base_url is None
99+
100+
101+
def test_client_config_rejects_empty_base_url() -> None:
102+
with pytest.raises(ValueError, match="base_url must be a non-empty string or None"):
103+
ClientConfig(base_url="")
104+
105+
106+
def test_client_config_rejects_slash_only_base_url() -> None:
107+
with pytest.raises(ValueError, match="base_url must be a non-empty string or None"):
108+
ClientConfig(base_url="/")
109+
110+
111+
def test_client_config_rejects_multiple_slashes_only_base_url() -> None:
112+
with pytest.raises(ValueError, match="base_url must be a non-empty string or None"):
113+
ClientConfig(base_url="///")
114+
115+
116+
def test_client_config_rejects_non_str_base_url() -> None:
117+
with pytest.raises(ValueError, match="base_url must be a non-empty string or None"):
118+
ClientConfig(base_url=123) # ty: ignore[invalid-argument-type]

‎tests/test_request.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,84 @@ class _Marker:
143143
new = r.with_extension("marker", marker)
144144
assert new.extensions == {"marker": marker}
145145
assert new.extensions["marker"] is marker
146+
147+
148+
def test_request_rejects_empty_url() -> None:
149+
with pytest.raises(ValueError, match="url must be non-empty"):
150+
Request(method="GET", url="")
151+
152+
153+
def test_request_rejects_non_str_url() -> None:
154+
with pytest.raises(TypeError, match="url must be str"):
155+
Request(method="GET", url=None) # ty: ignore[invalid-argument-type]
156+
157+
158+
def test_with_url_rejects_empty() -> None:
159+
r = Request(method="GET", url="/")
160+
with pytest.raises(ValueError, match="url must be non-empty"):
161+
r.with_url("")
162+
163+
164+
def test_request_rejects_header_with_crlf_in_value() -> None:
165+
with pytest.raises(ValueError, match="header name and value must not contain CR or LF"):
166+
Request(method="GET", url="/", headers={"X-Trace": "value\r\nInjected: yes"})
167+
168+
169+
def test_request_rejects_header_with_crlf_in_name() -> None:
170+
with pytest.raises(ValueError, match="header name and value must not contain CR or LF"):
171+
Request(method="GET", url="/", headers={"X-Bad\r\nInjected": "value"})
172+
173+
174+
def test_request_rejects_empty_header_name() -> None:
175+
with pytest.raises(ValueError, match="header name and value must be non-empty"):
176+
Request(method="GET", url="/", headers={"": "value"})
177+
178+
179+
def test_request_rejects_empty_header_value() -> None:
180+
with pytest.raises(ValueError, match="header name and value must be non-empty"):
181+
Request(method="GET", url="/", headers={"X-Trace": ""})
182+
183+
184+
def test_request_rejects_non_str_header_value() -> None:
185+
with pytest.raises(TypeError, match="header name and value must be str"):
186+
Request(method="GET", url="/", headers={"X-Trace": None}) # ty: ignore[invalid-argument-type]
187+
188+
189+
def test_request_rejects_cookie_with_crlf() -> None:
190+
with pytest.raises(ValueError, match="cookie name and value must not contain CR or LF"):
191+
Request(method="GET", url="/", cookies={"session": "abc\r\nSet-Cookie: evil"})
192+
193+
194+
def test_request_rejects_empty_cookie_value() -> None:
195+
with pytest.raises(ValueError, match="cookie name and value must be non-empty"):
196+
Request(method="GET", url="/", cookies={"session": ""})
197+
198+
199+
def test_with_header_rejects_crlf() -> None:
200+
r = Request(method="GET", url="/")
201+
with pytest.raises(ValueError, match="header name and value must not contain CR or LF"):
202+
r.with_header("X-Trace", "value\r\n")
203+
204+
205+
def test_with_cookie_rejects_crlf() -> None:
206+
r = Request(method="GET", url="/")
207+
with pytest.raises(ValueError, match="cookie name and value must not contain CR or LF"):
208+
r.with_cookie("session", "abc\r\n")
209+
210+
211+
@pytest.mark.parametrize("field_name", ["headers", "params", "cookies", "extensions"])
212+
def test_request_rejects_none_mapping_field(field_name: str) -> None:
213+
with pytest.raises(TypeError, match=f"{field_name} must be a Mapping"):
214+
Request(method="GET", url="/", **{field_name: None}) # ty: ignore[invalid-argument-type]
215+
216+
217+
@pytest.mark.parametrize("field_name", ["headers", "params", "cookies", "extensions"])
218+
def test_request_rejects_list_mapping_field(field_name: str) -> None:
219+
with pytest.raises(TypeError, match=f"{field_name} must be a Mapping"):
220+
Request(method="GET", url="/", **{field_name: []}) # ty: ignore[invalid-argument-type]
221+
222+
223+
def test_with_query_none_raises() -> None:
224+
r = Request(method="GET", url="/")
225+
with pytest.raises(TypeError, match="params must be a Mapping"):
226+
r.with_query(None) # ty: ignore[invalid-argument-type]

‎tests/test_response.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from httpware import Response
9+
from httpware.response import _parse_charset
910

1011

1112
def test_response_is_frozen() -> None:
@@ -68,6 +69,24 @@ def test_response_text_strips_quotes_around_charset(content_type: str) -> None:
6869
assert resp.text == "café"
6970

7071

72+
def test_response_text_strips_inner_whitespace_in_quoted_charset() -> None:
73+
# Direct parser check: inner whitespace inside the quoted value must not survive.
74+
# (Python's codec registry happens to normalize whitespace, masking the bug
75+
# end-to-end on most charsets; verify the parser itself returns a clean value.)
76+
assert _parse_charset('text/plain; charset=" iso-8859-1 "') == "iso-8859-1"
77+
78+
# End-to-end smoke: decoding the Latin-1 body must yield the original text.
79+
body = "café".encode("iso-8859-1")
80+
resp = Response(
81+
status=HTTPStatus.OK,
82+
headers={"content-type": 'text/plain; charset=" iso-8859-1 "'},
83+
content=body,
84+
url="/",
85+
elapsed=0.0,
86+
)
87+
assert resp.text == "café"
88+
89+
7190
def test_response_text_falls_back_to_utf8_on_unknown_charset() -> None:
7291
resp = Response(
7392
status=200,

0 commit comments

Comments
 (0)