Skip to content

Commit 8f0ba93

Browse files
Harden transport api_key normalization error boundaries
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 768e86d commit 8f0ba93

4 files changed

Lines changed: 122 additions & 22 deletions

File tree

hyperbrowser/transport/async_transport.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from hyperbrowser.exceptions import HyperbrowserError
55
from hyperbrowser.header_utils import merge_headers
66
from hyperbrowser.version import __version__
7-
from .base import APIResponse, AsyncTransportStrategy
7+
from .base import APIResponse, AsyncTransportStrategy, _normalize_transport_api_key
88
from .error_utils import (
99
extract_error_message,
1010
format_generic_request_failure_message,
@@ -19,16 +19,7 @@ class AsyncTransport(AsyncTransportStrategy):
1919
_MAX_HTTP_STATUS_CODE = 599
2020

2121
def __init__(self, api_key: str, headers: Optional[Mapping[str, str]] = None):
22-
if not isinstance(api_key, str):
23-
raise HyperbrowserError("api_key must be a string")
24-
normalized_api_key = api_key.strip()
25-
if not normalized_api_key:
26-
raise HyperbrowserError("api_key must not be empty")
27-
if any(
28-
ord(character) < 32 or ord(character) == 127
29-
for character in normalized_api_key
30-
):
31-
raise HyperbrowserError("api_key must not contain control characters")
22+
normalized_api_key = _normalize_transport_api_key(api_key)
3223
merged_headers = merge_headers(
3324
{
3425
"x-api-key": normalized_api_key,

hyperbrowser/transport/base.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,44 @@ def _format_mapping_key_for_error(key: str) -> str:
5151
return "<blank key>"
5252

5353

54+
def _normalize_transport_api_key(api_key: str) -> str:
55+
if not isinstance(api_key, str):
56+
raise HyperbrowserError("api_key must be a string")
57+
58+
try:
59+
normalized_api_key = api_key.strip()
60+
if not isinstance(normalized_api_key, str):
61+
raise TypeError("normalized api_key must be a string")
62+
except HyperbrowserError:
63+
raise
64+
except Exception as exc:
65+
raise HyperbrowserError(
66+
"Failed to normalize api_key",
67+
original_error=exc,
68+
) from exc
69+
70+
if not normalized_api_key:
71+
raise HyperbrowserError("api_key must not be empty")
72+
73+
try:
74+
contains_control_character = any(
75+
ord(character) < 32 or ord(character) == 127
76+
for character in normalized_api_key
77+
)
78+
except HyperbrowserError:
79+
raise
80+
except Exception as exc:
81+
raise HyperbrowserError(
82+
"Failed to validate api_key characters",
83+
original_error=exc,
84+
) from exc
85+
86+
if contains_control_character:
87+
raise HyperbrowserError("api_key must not contain control characters")
88+
89+
return normalized_api_key
90+
91+
5492
class APIResponse(Generic[T]):
5593
"""
5694
Wrapper for API responses to standardize sync/async handling.

hyperbrowser/transport/sync.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from hyperbrowser.exceptions import HyperbrowserError
55
from hyperbrowser.header_utils import merge_headers
66
from hyperbrowser.version import __version__
7-
from .base import APIResponse, SyncTransportStrategy
7+
from .base import APIResponse, SyncTransportStrategy, _normalize_transport_api_key
88
from .error_utils import (
99
extract_error_message,
1010
format_generic_request_failure_message,
@@ -19,16 +19,7 @@ class SyncTransport(SyncTransportStrategy):
1919
_MAX_HTTP_STATUS_CODE = 599
2020

2121
def __init__(self, api_key: str, headers: Optional[Mapping[str, str]] = None):
22-
if not isinstance(api_key, str):
23-
raise HyperbrowserError("api_key must be a string")
24-
normalized_api_key = api_key.strip()
25-
if not normalized_api_key:
26-
raise HyperbrowserError("api_key must not be empty")
27-
if any(
28-
ord(character) < 32 or ord(character) == 127
29-
for character in normalized_api_key
30-
):
31-
raise HyperbrowserError("api_key must not contain control characters")
22+
normalized_api_key = _normalize_transport_api_key(api_key)
3223
merged_headers = merge_headers(
3324
{
3425
"x-api-key": normalized_api_key,

tests/test_transport_api_key.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import pytest
2+
3+
from hyperbrowser.exceptions import HyperbrowserError
4+
from hyperbrowser.transport.async_transport import AsyncTransport
5+
from hyperbrowser.transport.sync import SyncTransport
6+
7+
8+
@pytest.mark.parametrize("transport_class", [SyncTransport, AsyncTransport])
9+
def test_transport_wraps_api_key_strip_runtime_errors(transport_class):
10+
class _BrokenStripApiKey(str):
11+
def strip(self, chars=None): # type: ignore[override]
12+
_ = chars
13+
raise RuntimeError("api key strip exploded")
14+
15+
with pytest.raises(HyperbrowserError, match="Failed to normalize api_key") as exc_info:
16+
transport_class(api_key=_BrokenStripApiKey("test-key"))
17+
18+
assert isinstance(exc_info.value.original_error, RuntimeError)
19+
20+
21+
@pytest.mark.parametrize("transport_class", [SyncTransport, AsyncTransport])
22+
def test_transport_preserves_hyperbrowser_api_key_strip_errors(transport_class):
23+
class _BrokenStripApiKey(str):
24+
def strip(self, chars=None): # type: ignore[override]
25+
_ = chars
26+
raise HyperbrowserError("custom strip failure")
27+
28+
with pytest.raises(HyperbrowserError, match="custom strip failure") as exc_info:
29+
transport_class(api_key=_BrokenStripApiKey("test-key"))
30+
31+
assert exc_info.value.original_error is None
32+
33+
34+
@pytest.mark.parametrize("transport_class", [SyncTransport, AsyncTransport])
35+
def test_transport_wraps_non_string_api_key_strip_results(transport_class):
36+
class _NonStringStripResultApiKey(str):
37+
def strip(self, chars=None): # type: ignore[override]
38+
_ = chars
39+
return object()
40+
41+
with pytest.raises(HyperbrowserError, match="Failed to normalize api_key") as exc_info:
42+
transport_class(api_key=_NonStringStripResultApiKey("test-key"))
43+
44+
assert isinstance(exc_info.value.original_error, TypeError)
45+
46+
47+
@pytest.mark.parametrize("transport_class", [SyncTransport, AsyncTransport])
48+
def test_transport_wraps_api_key_character_iteration_failures(transport_class):
49+
class _BrokenIterApiKey(str):
50+
def strip(self, chars=None): # type: ignore[override]
51+
_ = chars
52+
return self
53+
54+
def __iter__(self):
55+
raise RuntimeError("api key iteration exploded")
56+
57+
with pytest.raises(
58+
HyperbrowserError, match="Failed to validate api_key characters"
59+
) as exc_info:
60+
transport_class(api_key=_BrokenIterApiKey("test-key"))
61+
62+
assert isinstance(exc_info.value.original_error, RuntimeError)
63+
64+
65+
@pytest.mark.parametrize("transport_class", [SyncTransport, AsyncTransport])
66+
def test_transport_preserves_hyperbrowser_api_key_character_iteration_failures(
67+
transport_class,
68+
):
69+
class _BrokenIterApiKey(str):
70+
def strip(self, chars=None): # type: ignore[override]
71+
_ = chars
72+
return self
73+
74+
def __iter__(self):
75+
raise HyperbrowserError("custom iteration failure")
76+
77+
with pytest.raises(HyperbrowserError, match="custom iteration failure") as exc_info:
78+
transport_class(api_key=_BrokenIterApiKey("test-key"))
79+
80+
assert exc_info.value.original_error is None

0 commit comments

Comments
 (0)