From 05f345c6ca9a541ffe3081cd3df3cd6f231b3f84 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 15:07:59 +0200 Subject: [PATCH 1/7] fix: trim whitespace from API keys and host config --- posthog/client.py | 14 +++++++++++--- posthog/request.py | 14 +++++++++++--- posthog/test/test_client.py | 20 ++++++++++++++++++++ posthog/test/test_request.py | 2 ++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 9672d9e4..7d4f4b11 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -54,6 +54,7 @@ determine_server_host, flags, get, + normalize_host, remote_config, ) from posthog.types import ( @@ -221,14 +222,14 @@ def __init__( self.queue = queue.Queue(max_queue_size) # api_key: This should be the Team API Key (token), public - self.api_key = project_api_key + self.api_key = project_api_key.strip() self.on_error = on_error self.debug = debug self.send = send self.sync_mode = sync_mode # Used for session replay URL generation - we don't want the server host here. - self.raw_host = host or DEFAULT_HOST + self.raw_host = normalize_host(host) self.host = determine_server_host(host) self.gzip = gzip self.timeout = timeout @@ -278,7 +279,9 @@ def __init__( self.project_root = project_root # personal_api_key: This should be a generated Personal API Key, private - self.personal_api_key = personal_api_key + self.personal_api_key = ( + personal_api_key.strip() if isinstance(personal_api_key, str) else personal_api_key + ) or None if debug: # Ensures that debug level messages are logged when debug mode is on. # Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided @@ -287,6 +290,11 @@ def __init__( else: self.log.setLevel(logging.WARNING) + if not self.api_key: + self.log.error( + "api_key is empty after trimming whitespace; check your project API key" + ) + self._set_before_send(before_send) if self.enable_exception_autocapture: diff --git a/posthog/request.py b/posthog/request.py index 3d335d17..96907109 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -165,9 +165,17 @@ def disable_connection_reuse() -> None: USER_AGENT = "posthog-python/" + VERSION +def normalize_host(host: Optional[str]) -> str: + """Normalize a configured host, defaulting blank values to DEFAULT_HOST.""" + normalized_host = (host or "").strip() + if not normalized_host: + return DEFAULT_HOST + return normalized_host + + def determine_server_host(host: Optional[str]) -> str: """Determines the server host to use.""" - host_or_default = host or DEFAULT_HOST + host_or_default = normalize_host(host) trimmed_host = remove_trailing_slash(host_or_default) if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"): return US_INGESTION_ENDPOINT @@ -190,7 +198,7 @@ def post( log = logging.getLogger("posthog") body = kwargs body["sentAt"] = datetime.now(tz=tzutc()).isoformat() - url = remove_trailing_slash(host or DEFAULT_HOST) + path + url = remove_trailing_slash(normalize_host(host)) + path body["api_key"] = api_key data = json.dumps(body, cls=DatetimeSerializer) log.debug("making request: %s to url: %s", data, url) @@ -330,7 +338,7 @@ def get( - not_modified=False and data=response if server returns 200 """ log = logging.getLogger("posthog") - full_url = remove_trailing_slash(host or DEFAULT_HOST) + url + full_url = remove_trailing_slash(normalize_host(host)) + url headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT} if etag: diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 9cbc206c..aaf0d8ad 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -41,6 +41,26 @@ def setUp(self): def test_requires_api_key(self): self.assertRaises(TypeError, Client) + def test_trims_whitespace_sensitive_config(self): + with self.assertLogs("posthog", level="ERROR") as logs: + client = Client( + " \n\t ", + host=" \nhttps://eu.posthog.com/\t ", + personal_api_key=" \n\t ", + send=False, + ) + + self.assertEqual(client.api_key, "") + self.assertEqual(client.raw_host, "https://eu.posthog.com/") + self.assertEqual(client.host, "https://eu.i.posthog.com") + self.assertIsNone(client.personal_api_key) + self.assertTrue( + any( + "api_key is empty after trimming whitespace" in message + for message in logs.output + ) + ) + def test_empty_flush(self): self.client.flush() diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index c87af36b..3529d907 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -348,6 +348,8 @@ def test_get_removes_trailing_slash_from_host(self, mock_get): ("https://app.posthog.com/", "https://us.i.posthog.com"), ("https://eu.posthog.com/", "https://eu.i.posthog.com"), ("https://us.posthog.com/", "https://us.i.posthog.com"), + (" \nhttps://eu.posthog.com/\t ", "https://eu.i.posthog.com"), + (" \n\t ", "https://us.i.posthog.com"), (None, "https://us.i.posthog.com"), ], ) From 6b9b57fcbe1f02c711eb25bce76dcafe9e26dc69 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 16:07:32 +0200 Subject: [PATCH 2/7] refactor: reuse normalized host when building request URLs --- posthog/request.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/posthog/request.py b/posthog/request.py index 96907109..fa54fac7 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -198,7 +198,8 @@ def post( log = logging.getLogger("posthog") body = kwargs body["sentAt"] = datetime.now(tz=tzutc()).isoformat() - url = remove_trailing_slash(normalize_host(host)) + path + trimmed_host = remove_trailing_slash(normalize_host(host)) + url = trimmed_host + path body["api_key"] = api_key data = json.dumps(body, cls=DatetimeSerializer) log.debug("making request: %s to url: %s", data, url) @@ -338,7 +339,8 @@ def get( - not_modified=False and data=response if server returns 200 """ log = logging.getLogger("posthog") - full_url = remove_trailing_slash(normalize_host(host)) + url + trimmed_host = remove_trailing_slash(normalize_host(host)) + full_url = trimmed_host + url headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT} if etag: From 0afd2a17fe4f6aa079ebc31019bee15cf92a5a5b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 16:15:31 +0200 Subject: [PATCH 3/7] test: cover trimming padded api keys --- posthog/test/test_client.py | 40 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index aaf0d8ad..587e7005 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -41,25 +41,37 @@ def setUp(self): def test_requires_api_key(self): self.assertRaises(TypeError, Client) - def test_trims_whitespace_sensitive_config(self): - with self.assertLogs("posthog", level="ERROR") as logs: - client = Client( - " \n\t ", - host=" \nhttps://eu.posthog.com/\t ", - personal_api_key=" \n\t ", - send=False, + @parameterized.expand( + [ + ("valid_key", " \nphc_validkey\t ", "phc_validkey", False), + ("whitespace_only", " \n\t ", "", True), + ] + ) + def test_trims_api_key_whitespace( + self, _, raw_api_key, expected_api_key, expect_error_log + ): + with mock.patch.object(Client.log, "error") as mock_error: + client = Client(raw_api_key, send=False) + + self.assertEqual(client.api_key, expected_api_key) + if expect_error_log: + mock_error.assert_called_once_with( + "api_key is empty after trimming whitespace; check your project API key" ) + else: + mock_error.assert_not_called() + + def test_trims_host_and_personal_api_key_whitespace(self): + client = Client( + FAKE_TEST_API_KEY, + host=" \nhttps://eu.posthog.com/\t ", + personal_api_key=" \n\t ", + send=False, + ) - self.assertEqual(client.api_key, "") self.assertEqual(client.raw_host, "https://eu.posthog.com/") self.assertEqual(client.host, "https://eu.i.posthog.com") self.assertIsNone(client.personal_api_key) - self.assertTrue( - any( - "api_key is empty after trimming whitespace" in message - for message in logs.output - ) - ) def test_empty_flush(self): self.client.flush() From e6c9375d222abeab52621c4a37691adf11c4e5a0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 16:15:59 +0200 Subject: [PATCH 4/7] style: format client with ruff --- posthog/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/posthog/client.py b/posthog/client.py index 7d4f4b11..4904602b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -280,7 +280,9 @@ def __init__( # personal_api_key: This should be a generated Personal API Key, private self.personal_api_key = ( - personal_api_key.strip() if isinstance(personal_api_key, str) else personal_api_key + personal_api_key.strip() + if isinstance(personal_api_key, str) + else personal_api_key ) or None if debug: # Ensures that debug level messages are logged when debug mode is on. From 593dd66b1850edbb39f57daabfd6b0817b0857d3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 16:16:38 +0200 Subject: [PATCH 5/7] chore: add release changeset --- .sampo/changesets/trim-whitespace-config-values.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/trim-whitespace-config-values.md diff --git a/.sampo/changesets/trim-whitespace-config-values.md b/.sampo/changesets/trim-whitespace-config-values.md new file mode 100644 index 00000000..83f3a805 --- /dev/null +++ b/.sampo/changesets/trim-whitespace-config-values.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Trim surrounding whitespace from API keys and host config before using them. From 2a6457aef1cc3a7ea70597a0be7ed7e2a627a1ad Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 16:18:07 +0200 Subject: [PATCH 6/7] style: remove unused request import --- posthog/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/posthog/client.py b/posthog/client.py index 4904602b..80591999 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -45,7 +45,6 @@ ) from posthog.poller import Poller from posthog.request import ( - DEFAULT_HOST, APIError, QuotaLimitError, RequestsConnectionError, From ab551d7f7453e73c58ed08c41044db7843dcc5cf Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 20 Apr 2026 16:20:49 +0200 Subject: [PATCH 7/7] fix: guard missing personal api key in flag fetch --- posthog/client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/posthog/client.py b/posthog/client.py index 80591999..c0c5491c 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1297,12 +1297,19 @@ def _load_feature_flags(self): def _fetch_feature_flags_from_api(self): """Fetch feature flags from the PostHog API.""" + personal_api_key = self.personal_api_key + if personal_api_key is None: + self.log.warning( + "[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags." + ) + return + try: # Store old flags to detect changes old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {} response = get( - self.personal_api_key, + personal_api_key, f"/flags/definitions?token={self.api_key}&send_cohorts", self.host, timeout=10,