diff --git a/googleapiclient/channel.py b/googleapiclient/channel.py index 37bda1ea7e..88349b5871 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" @@ -107,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. @@ -261,7 +269,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 +280,25 @@ 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. 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( + _to_bytes(received_token), _to_bytes(channel.token) + ): + 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 2e8b2f2fca..1ce8cfab81 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,63 @@ 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) + + # 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" + ) + n = channel.notification_from_headers(ch_no_token, dict(headers)) + self.assertEqual(1, n.message_number)