Skip to content

Commit dc49768

Browse files
Harden ClientConfig api_key normalization boundaries
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent a1482b1 commit dc49768

File tree

2 files changed

+111
-10
lines changed

2 files changed

+111
-10
lines changed

hyperbrowser/config.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,50 @@ class ClientConfig:
1919
headers: Optional[Mapping[str, str]] = None
2020

2121
def __post_init__(self) -> None:
22-
if not isinstance(self.api_key, str):
23-
raise HyperbrowserError("api_key must be a string")
24-
self.api_key = self.api_key.strip()
25-
if not self.api_key:
26-
raise HyperbrowserError("api_key must not be empty")
27-
if any(
28-
ord(character) < 32 or ord(character) == 127 for character in self.api_key
29-
):
30-
raise HyperbrowserError("api_key must not contain control characters")
22+
self.api_key = self.normalize_api_key(self.api_key)
3123
self.base_url = self.normalize_base_url(self.base_url)
3224
self.headers = normalize_headers(
3325
self.headers,
3426
mapping_error_message="headers must be a mapping of string pairs",
3527
)
3628

29+
@staticmethod
30+
def normalize_api_key(
31+
api_key: str,
32+
*,
33+
empty_error_message: str = "api_key must not be empty",
34+
) -> str:
35+
if not isinstance(api_key, str):
36+
raise HyperbrowserError("api_key must be a string")
37+
try:
38+
normalized_api_key = api_key.strip()
39+
if not isinstance(normalized_api_key, str):
40+
raise TypeError("normalized api_key must be a string")
41+
except HyperbrowserError:
42+
raise
43+
except Exception as exc:
44+
raise HyperbrowserError(
45+
"Failed to normalize api_key",
46+
original_error=exc,
47+
) from exc
48+
if not normalized_api_key:
49+
raise HyperbrowserError(empty_error_message)
50+
try:
51+
contains_control_character = any(
52+
ord(character) < 32 or ord(character) == 127
53+
for character in normalized_api_key
54+
)
55+
except HyperbrowserError:
56+
raise
57+
except Exception as exc:
58+
raise HyperbrowserError(
59+
"Failed to validate api_key characters",
60+
original_error=exc,
61+
) from exc
62+
if contains_control_character:
63+
raise HyperbrowserError("api_key must not contain control characters")
64+
return normalized_api_key
65+
3766
@staticmethod
3867
def _decode_url_component_with_limit(value: str, *, component_label: str) -> str:
3968
decoded_value = value
@@ -264,10 +293,14 @@ def normalize_base_url(base_url: str) -> str:
264293
@classmethod
265294
def from_env(cls) -> "ClientConfig":
266295
api_key = os.environ.get("HYPERBROWSER_API_KEY")
267-
if api_key is None or not api_key.strip():
296+
if api_key is None:
268297
raise HyperbrowserError(
269298
"HYPERBROWSER_API_KEY environment variable is required"
270299
)
300+
api_key = cls.normalize_api_key(
301+
api_key,
302+
empty_error_message="HYPERBROWSER_API_KEY environment variable is required",
303+
)
271304

272305
base_url = cls.resolve_base_url_from_env(
273306
os.environ.get("HYPERBROWSER_BASE_URL")

tests/test_config.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,74 @@ def test_client_config_rejects_non_string_values():
211211
ClientConfig(api_key="bad\nkey")
212212

213213

214+
def test_client_config_wraps_api_key_strip_runtime_errors():
215+
class _BrokenApiKey(str):
216+
def strip(self, chars=None): # type: ignore[override]
217+
_ = chars
218+
raise RuntimeError("api key strip exploded")
219+
220+
with pytest.raises(HyperbrowserError, match="Failed to normalize api_key") as exc_info:
221+
ClientConfig(api_key=_BrokenApiKey("test-key"))
222+
223+
assert isinstance(exc_info.value.original_error, RuntimeError)
224+
225+
226+
def test_client_config_preserves_hyperbrowser_api_key_strip_errors():
227+
class _BrokenApiKey(str):
228+
def strip(self, chars=None): # type: ignore[override]
229+
_ = chars
230+
raise HyperbrowserError("custom strip failure")
231+
232+
with pytest.raises(HyperbrowserError, match="custom strip failure") as exc_info:
233+
ClientConfig(api_key=_BrokenApiKey("test-key"))
234+
235+
assert exc_info.value.original_error is None
236+
237+
238+
def test_client_config_wraps_non_string_api_key_strip_results():
239+
class _BrokenApiKey(str):
240+
def strip(self, chars=None): # type: ignore[override]
241+
_ = chars
242+
return object()
243+
244+
with pytest.raises(HyperbrowserError, match="Failed to normalize api_key") as exc_info:
245+
ClientConfig(api_key=_BrokenApiKey("test-key"))
246+
247+
assert isinstance(exc_info.value.original_error, TypeError)
248+
249+
250+
def test_client_config_wraps_api_key_iteration_runtime_errors():
251+
class _BrokenApiKey(str):
252+
def strip(self, chars=None): # type: ignore[override]
253+
_ = chars
254+
return self
255+
256+
def __iter__(self):
257+
raise RuntimeError("api key iteration exploded")
258+
259+
with pytest.raises(
260+
HyperbrowserError, match="Failed to validate api_key characters"
261+
) as exc_info:
262+
ClientConfig(api_key=_BrokenApiKey("test-key"))
263+
264+
assert isinstance(exc_info.value.original_error, RuntimeError)
265+
266+
267+
def test_client_config_preserves_hyperbrowser_api_key_iteration_errors():
268+
class _BrokenApiKey(str):
269+
def strip(self, chars=None): # type: ignore[override]
270+
_ = chars
271+
return self
272+
273+
def __iter__(self):
274+
raise HyperbrowserError("custom iteration failure")
275+
276+
with pytest.raises(HyperbrowserError, match="custom iteration failure") as exc_info:
277+
ClientConfig(api_key=_BrokenApiKey("test-key"))
278+
279+
assert exc_info.value.original_error is None
280+
281+
214282
def test_client_config_rejects_empty_or_invalid_base_url():
215283
with pytest.raises(HyperbrowserError, match="base_url must not be empty"):
216284
ClientConfig(api_key="test-key", base_url=" ")

0 commit comments

Comments
 (0)