diff --git a/README.md b/README.md index 410c741..9dece25 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ This SDK contains methods for interacting easily with ZeroBounce API. More information about ZeroBounce you can find in the [official documentation](https://www.zerobounce.net/docs/). + +## Security + +- Keep API keys on a trusted server. Do not embed them in mobile apps or browser JavaScript that untrusted users can inspect. +- Custom API base URLs (when supported) must use `https://`. Do not pass end-user-controlled hosts into those settings. +- Request URLs include `api_key` as a query parameter (ZeroBounce API contract). Do not log full request URLs or enable payload debug logging in production. + ## INSTALLATION ```bash pip install zerobouncesdk diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 08dfe36..a106f26 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -32,6 +32,13 @@ class ZeroBounce: BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2" SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" + DEFAULT_HTTP_TIMEOUT_S = 120.0 + + @staticmethod + def _require_https_url(url: str) -> str: + if not url.lower().startswith("https://"): + raise ZBClientException("base_url must be an https:// URL") + return url.rstrip("/") def __init__(self, api_key: str, base_url: Optional[Union[ZBApiUrl, str]] = None, timeout: timedelta | None = None): """Initialize the ZeroBounce client. @@ -60,14 +67,16 @@ def __init__(self, api_key: str, base_url: Optional[Union[ZBApiUrl, str]] = None elif isinstance(base_url, ZBApiUrl): self._base_url = base_url.value elif isinstance(base_url, str): - self._base_url = base_url + self._base_url = self._require_https_url(base_url) else: raise ZBClientException(f"Invalid base_url type: {type(base_url)}. Expected ZBApiUrl, str, or None.") # Remove trailing slash if present to maintain consistent URL construction self._base_url = self._base_url.rstrip('/') - self._timeout_s: float | None = None if timeout is None else timeout.total_seconds() + self._timeout_s: float = ( + self.DEFAULT_HTTP_TIMEOUT_S if timeout is None else timeout.total_seconds() + ) @staticmethod def get_file_json_indicates_error(body: str) -> bool: diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 9c3373b..47e2db1 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -31,6 +31,19 @@ def test_init_blank_key(self): expected_error = "Empty parameter: api_key" self.assertEqual(str(cm.exception), expected_error) + def test_init_rejects_http_base_url(self): + with self.assertRaises(ZBClientException) as cm: + ZeroBounce("dummy_key", base_url="http://evil.example/v2") + self.assertIn("https://", str(cm.exception)) + + def test_default_http_timeout(self): + self.requests_mock.get.return_value = MockResponse({'a': 'b'}) + client = ZeroBounce("dummy_key") + client._get("https://example.com", ZBResponse) + self.requests_mock.get.assert_called_with( + 'https://example.com', params={'api_key': 'dummy_key'}, timeout=120.0 + ) + def test_credits_invalid_key(self): self.requests_mock.get.return_value = MockResponse({ "Credits": "-1",