Skip to content

Commit 70f6604

Browse files
Harden base URL and headers env normalization boundaries
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 7815f03 commit 70f6604

4 files changed

Lines changed: 136 additions & 5 deletions

File tree

hyperbrowser/config.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,20 @@ def _safe_unquote(value: str, *, component_label: str) -> str:
105105
def normalize_base_url(base_url: str) -> str:
106106
if not isinstance(base_url, str):
107107
raise HyperbrowserError("base_url must be a string")
108-
normalized_base_url = base_url.strip().rstrip("/")
108+
try:
109+
stripped_base_url = base_url.strip()
110+
if type(stripped_base_url) is not str:
111+
raise TypeError("normalized base_url must be a string")
112+
normalized_base_url = stripped_base_url.rstrip("/")
113+
if type(normalized_base_url) is not str:
114+
raise TypeError("normalized base_url must be a string")
115+
except HyperbrowserError:
116+
raise
117+
except Exception as exc:
118+
raise HyperbrowserError(
119+
"Failed to normalize base_url",
120+
original_error=exc,
121+
) from exc
109122
if not normalized_base_url:
110123
raise HyperbrowserError("base_url must not be empty")
111124
if "\n" in normalized_base_url or "\r" in normalized_base_url:
@@ -318,6 +331,17 @@ def resolve_base_url_from_env(raw_base_url: Optional[str]) -> str:
318331
return "https://api.hyperbrowser.ai"
319332
if not isinstance(raw_base_url, str):
320333
raise HyperbrowserError("HYPERBROWSER_BASE_URL must be a string")
321-
if not raw_base_url.strip():
334+
try:
335+
normalized_env_base_url = raw_base_url.strip()
336+
if not isinstance(normalized_env_base_url, str):
337+
raise TypeError("normalized environment base_url must be a string")
338+
except HyperbrowserError:
339+
raise
340+
except Exception as exc:
341+
raise HyperbrowserError(
342+
"Failed to normalize HYPERBROWSER_BASE_URL",
343+
original_error=exc,
344+
) from exc
345+
if not normalized_env_base_url:
322346
raise HyperbrowserError("HYPERBROWSER_BASE_URL must not be empty when set")
323-
return ClientConfig.normalize_base_url(raw_base_url)
347+
return ClientConfig.normalize_base_url(normalized_env_base_url)

hyperbrowser/header_utils.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,21 @@ def parse_headers_env_json(raw_headers: Optional[str]) -> Optional[Dict[str, str
141141
return None
142142
if not isinstance(raw_headers, str):
143143
raise HyperbrowserError("HYPERBROWSER_HEADERS must be a string")
144-
if not raw_headers.strip():
144+
try:
145+
normalized_raw_headers = raw_headers.strip()
146+
if not isinstance(normalized_raw_headers, str):
147+
raise TypeError("normalized headers payload must be a string")
148+
except HyperbrowserError:
149+
raise
150+
except Exception as exc:
151+
raise HyperbrowserError(
152+
"Failed to normalize HYPERBROWSER_HEADERS",
153+
original_error=exc,
154+
) from exc
155+
if not normalized_raw_headers:
145156
return None
146157
try:
147-
parsed_headers = json.loads(raw_headers)
158+
parsed_headers = json.loads(normalized_raw_headers)
148159
except HyperbrowserError:
149160
raise
150161
except Exception as exc:

tests/test_config.py

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

213213

214+
def test_client_config_normalize_base_url_wraps_strip_runtime_errors():
215+
class _BrokenBaseUrl(str):
216+
def strip(self, chars=None): # type: ignore[override]
217+
_ = chars
218+
raise RuntimeError("base_url strip exploded")
219+
220+
with pytest.raises(HyperbrowserError, match="Failed to normalize base_url") as exc_info:
221+
ClientConfig.normalize_base_url(_BrokenBaseUrl("https://example.local"))
222+
223+
assert isinstance(exc_info.value.original_error, RuntimeError)
224+
225+
226+
def test_client_config_normalize_base_url_preserves_hyperbrowser_strip_errors():
227+
class _BrokenBaseUrl(str):
228+
def strip(self, chars=None): # type: ignore[override]
229+
_ = chars
230+
raise HyperbrowserError("custom base_url strip failure")
231+
232+
with pytest.raises(
233+
HyperbrowserError, match="custom base_url strip failure"
234+
) as exc_info:
235+
ClientConfig.normalize_base_url(_BrokenBaseUrl("https://example.local"))
236+
237+
assert exc_info.value.original_error is None
238+
239+
240+
def test_client_config_normalize_base_url_wraps_non_string_strip_results():
241+
class _BrokenBaseUrl(str):
242+
def strip(self, chars=None): # type: ignore[override]
243+
_ = chars
244+
return object()
245+
246+
with pytest.raises(HyperbrowserError, match="Failed to normalize base_url") as exc_info:
247+
ClientConfig.normalize_base_url(_BrokenBaseUrl("https://example.local"))
248+
249+
assert isinstance(exc_info.value.original_error, TypeError)
250+
251+
252+
def test_client_config_resolve_base_url_from_env_wraps_strip_runtime_errors():
253+
class _BrokenBaseUrl(str):
254+
def strip(self, chars=None): # type: ignore[override]
255+
_ = chars
256+
raise RuntimeError("environment base_url strip exploded")
257+
258+
with pytest.raises(
259+
HyperbrowserError, match="Failed to normalize HYPERBROWSER_BASE_URL"
260+
) as exc_info:
261+
ClientConfig.resolve_base_url_from_env(_BrokenBaseUrl("https://example.local"))
262+
263+
assert isinstance(exc_info.value.original_error, RuntimeError)
264+
265+
214266
def test_client_config_wraps_api_key_strip_runtime_errors():
215267
class _BrokenApiKey(str):
216268
def strip(self, chars=None): # type: ignore[override]

tests/test_header_utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ def lower(self): # type: ignore[override]
6060
return object()
6161

6262

63+
class _BrokenHeadersEnvString(str):
64+
def strip(self, chars=None): # type: ignore[override]
65+
_ = chars
66+
raise RuntimeError("headers env strip exploded")
67+
68+
69+
class _NonStringHeadersEnvStripResult(str):
70+
def strip(self, chars=None): # type: ignore[override]
71+
_ = chars
72+
return object()
73+
74+
6375
def test_normalize_headers_trims_header_names():
6476
headers = normalize_headers(
6577
{" X-Correlation-Id ": "abc123"},
@@ -192,6 +204,38 @@ def test_parse_headers_env_json_rejects_non_string_input():
192204
parse_headers_env_json(123) # type: ignore[arg-type]
193205

194206

207+
def test_parse_headers_env_json_wraps_strip_runtime_errors():
208+
with pytest.raises(
209+
HyperbrowserError, match="Failed to normalize HYPERBROWSER_HEADERS"
210+
) as exc_info:
211+
parse_headers_env_json(_BrokenHeadersEnvString('{"X-Trace-Id":"abc123"}'))
212+
213+
assert isinstance(exc_info.value.original_error, RuntimeError)
214+
215+
216+
def test_parse_headers_env_json_preserves_hyperbrowser_strip_errors():
217+
class _BrokenHeadersEnvString(str):
218+
def strip(self, chars=None): # type: ignore[override]
219+
_ = chars
220+
raise HyperbrowserError("custom headers strip failure")
221+
222+
with pytest.raises(
223+
HyperbrowserError, match="custom headers strip failure"
224+
) as exc_info:
225+
parse_headers_env_json(_BrokenHeadersEnvString('{"X-Trace-Id":"abc123"}'))
226+
227+
assert exc_info.value.original_error is None
228+
229+
230+
def test_parse_headers_env_json_wraps_non_string_strip_results():
231+
with pytest.raises(
232+
HyperbrowserError, match="Failed to normalize HYPERBROWSER_HEADERS"
233+
) as exc_info:
234+
parse_headers_env_json(_NonStringHeadersEnvStripResult('{"X-Trace-Id":"abc123"}'))
235+
236+
assert isinstance(exc_info.value.original_error, TypeError)
237+
238+
195239
def test_parse_headers_env_json_rejects_invalid_json():
196240
with pytest.raises(
197241
HyperbrowserError, match="HYPERBROWSER_HEADERS must be valid JSON object"

0 commit comments

Comments
 (0)