From f3418287a3f68841c9b816e00706ff21d0c95b36 Mon Sep 17 00:00:00 2001 From: farkhalit rida Date: Wed, 30 Sep 2026 12:21:14 +0530 Subject: [PATCH 1/2] verify channel token in notification_from_headers --- googleapiclient/channel.py | 29 ++++++++++++++++++++------- tests/test_channel.py | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/googleapiclient/channel.py b/googleapiclient/channel.py index 37bda1ea7e5..3bf5f67a841 100644 --- a/googleapiclient/channel.py +++ b/googleapiclient/channel.py @@ -72,6 +72,7 @@ from __future__ import absolute_import import datetime +import hmac import uuid from googleapiclient import _helpers as util @@ -94,6 +95,7 @@ } X_GOOG_CHANNEL_ID = "X-GOOG-CHANNEL-ID" +X_GOOG_CHANNEL_TOKEN = "X-GOOG-CHANNEL-TOKEN" X_GOOG_MESSAGE_NUMBER = "X-GOOG-MESSAGE-NUMBER" X_GOOG_RESOURCE_STATE = "X-GOOG-RESOURCE-STATE" X_GOOG_RESOURCE_URI = "X-GOOG-RESOURCE-URI" @@ -261,7 +263,9 @@ def notification_from_headers(channel, headers): A Notification object. Raises: - errors.InvalidNotificationError if the notification is invalid. + errors.InvalidNotificationError if the notification is invalid, including + when the channel id or, if the channel was created with a token, the + channel token does not match. ValueError if the X-GOOG-MESSAGE-NUMBER can't be converted to an int. """ headers = _upper_header_keys(headers) @@ -270,12 +274,23 @@ def notification_from_headers(channel, headers): raise errors.InvalidNotificationError( "Channel id mismatch: %s != %s" % (channel.id, channel_id) ) - else: - message_number = int(headers[X_GOOG_MESSAGE_NUMBER]) - state = headers[X_GOOG_RESOURCE_STATE] - resource_uri = headers[X_GOOG_RESOURCE_URI] - resource_id = headers[X_GOOG_RESOURCE_ID] - return Notification(message_number, state, resource_uri, resource_id) + # When a channel was created with a token, that token is the shared secret + # that authenticates a notification as originating from Google. The channel + # id is echoed in every delivery and is not secret, so verifying it alone + # lets anyone who learns the id forge notifications. Verify the token here, + # using a constant-time comparison to avoid leaking it via timing. + if channel.token: + received_token = headers.get(X_GOOG_CHANNEL_TOKEN) + if received_token is None or not hmac.compare_digest( + received_token.encode("utf-8"), channel.token.encode("utf-8") + ): + raise errors.InvalidNotificationError("Channel token mismatch") + + message_number = int(headers[X_GOOG_MESSAGE_NUMBER]) + state = headers[X_GOOG_RESOURCE_STATE] + resource_uri = headers[X_GOOG_RESOURCE_URI] + resource_id = headers[X_GOOG_RESOURCE_ID] + return Notification(message_number, state, resource_uri, resource_id) @util.positional(2) diff --git a/tests/test_channel.py b/tests/test_channel.py index 2e8b2f2fca5..c365c773e9d 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -101,6 +101,7 @@ def test_basic(self): def test_notification_from_headers(self): headers = { "X-GoOG-CHANNEL-ID": "myid", + "X-Goog-CHANNEL-token": "mytoken", "X-Goog-MESSAGE-NUMBER": "1", "X-Goog-rESOURCE-STATE": "sync", "X-Goog-reSOURCE-URI": "http://example.com/", @@ -135,3 +136,43 @@ def test_notification_from_headers(self): # Set the id back to a correct value. ch.id = "myid" + + def test_notification_token_validation(self): + headers = { + "X-Goog-Channel-ID": "myid", + "X-Goog-Message-Number": "1", + "X-Goog-Resource-State": "sync", + "X-Goog-Resource-URI": "http://example.com/", + "X-Goog-Resource-ID": "http://example.com/resource_1", + } + + ch = channel.Channel( + "web_hook", + "myid", + "mytoken", + "http://example.org/callback", + ) + + # A matching id but a missing token must be rejected, otherwise anyone + # who learns the (non-secret) channel id can forge notifications. + with self.assertRaises(errors.InvalidNotificationError): + channel.notification_from_headers(ch, dict(headers)) + + # A wrong token is rejected too. + wrong = dict(headers) + wrong["X-Goog-Channel-Token"] = "nottheone" + with self.assertRaises(errors.InvalidNotificationError): + channel.notification_from_headers(ch, wrong) + + # The correct token is accepted. + good = dict(headers) + good["X-Goog-Channel-Token"] = "mytoken" + n = channel.notification_from_headers(ch, good) + self.assertEqual(1, n.message_number) + + # Channels created without a token keep the previous behavior. + ch_no_token = channel.Channel( + "web_hook", "myid", None, "http://example.org/callback" + ) + n = channel.notification_from_headers(ch_no_token, dict(headers)) + self.assertEqual(1, n.message_number) From 16dc01e9a06f7d1a0a11ea74e75c2efd4ebc489e Mon Sep 17 00:00:00 2001 From: farkhalit rida Date: Tue, 6 Oct 2026 00:56:09 +0530 Subject: [PATCH 2/2] fix: accept bytes channel tokens in notification_from_headers Header values, and the token a Channel was built with, can be bytes rather than str depending on the web framework. Calling .encode() on those raised AttributeError instead of verifying the token, so normalize both sides to bytes before the constant-time comparison. --- googleapiclient/channel.py | 12 ++++++++++-- tests/test_channel.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/googleapiclient/channel.py b/googleapiclient/channel.py index 3bf5f67a841..88349b58715 100644 --- a/googleapiclient/channel.py +++ b/googleapiclient/channel.py @@ -109,6 +109,12 @@ def _upper_header_keys(headers): return new_headers +def _to_bytes(value): + if isinstance(value, bytes): + return value + return value.encode("utf-8") + + class Notification(object): """A Notification from a Channel. @@ -278,11 +284,13 @@ def notification_from_headers(channel, headers): # that authenticates a notification as originating from Google. The channel # id is echoed in every delivery and is not secret, so verifying it alone # lets anyone who learns the id forge notifications. Verify the token here, - # using a constant-time comparison to avoid leaking it via timing. + # using a constant-time comparison to avoid leaking it via timing. Either + # side may be str or bytes depending on the web framework, so both are + # normalized to bytes before comparing. if channel.token: received_token = headers.get(X_GOOG_CHANNEL_TOKEN) if received_token is None or not hmac.compare_digest( - received_token.encode("utf-8"), channel.token.encode("utf-8") + _to_bytes(received_token), _to_bytes(channel.token) ): raise errors.InvalidNotificationError("Channel token mismatch") diff --git a/tests/test_channel.py b/tests/test_channel.py index c365c773e9d..1ce8cfab81e 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -170,6 +170,26 @@ def test_notification_token_validation(self): n = channel.notification_from_headers(ch, good) self.assertEqual(1, n.message_number) + # Header values can arrive as bytes depending on the web framework, and + # the token is compared the same way whether it is str or bytes. + good_bytes = dict(headers) + good_bytes["X-Goog-Channel-Token"] = b"mytoken" + n = channel.notification_from_headers(ch, good_bytes) + self.assertEqual(1, n.message_number) + + wrong_bytes = dict(headers) + wrong_bytes["X-Goog-Channel-Token"] = b"nottheone" + with self.assertRaises(errors.InvalidNotificationError): + channel.notification_from_headers(ch, wrong_bytes) + + ch_bytes_token = channel.Channel( + "web_hook", "myid", b"mytoken", "http://example.org/callback" + ) + n = channel.notification_from_headers(ch_bytes_token, good) + self.assertEqual(1, n.message_number) + n = channel.notification_from_headers(ch_bytes_token, good_bytes) + self.assertEqual(1, n.message_number) + # Channels created without a token keep the previous behavior. ch_no_token = channel.Channel( "web_hook", "myid", None, "http://example.org/callback"