Skip to content

Commit 0952cf9

Browse files
authored
Integrations: Don't allow webhooks without a secret (#11083)
* Integrations: Don't allow webhooks without a secret This is just a clean up, we already deprecated the use of webhooks without a secret. * Linter
1 parent 803646c commit 0952cf9

File tree

2 files changed

+7
-80
lines changed

2 files changed

+7
-80
lines changed

readthedocs/api/v2/views/integrations.py

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Endpoints integrating with Github, Bitbucket, and other webhooks."""
22

3-
import datetime
43
import hashlib
54
import hmac
65
import json
@@ -10,7 +9,6 @@
109

1110
import structlog
1211
from django.shortcuts import get_object_or_404
13-
from django.utils import timezone
1412
from django.utils.crypto import constant_time_compare
1513
from rest_framework import permissions, status
1614
from rest_framework.exceptions import NotFound, ParseError
@@ -74,14 +72,6 @@ class WebhookMixin:
7472
integration = None
7573
integration_type = None
7674
invalid_payload_msg = 'Payload not valid'
77-
missing_secret_for_pr_events_msg = dedent(
78-
"""
79-
This webhook doesn't have a secret configured.
80-
For security reasons, webhooks without a secret can't process pull/merge request events.
81-
For more information, read our blog post: https://blog.readthedocs.com/security-update-on-incoming-webhooks/.
82-
"""
83-
).strip()
84-
8575
missing_secret_deprecated_msg = dedent(
8676
"""
8777
This webhook doesn't have a secret configured.
@@ -116,12 +106,9 @@ def post(self, request, project_slug):
116106
except Project.DoesNotExist as exc:
117107
raise NotFound("Project not found") from exc
118108

119-
# Deprecate webhooks without a secret
109+
# Webhooks without a secret are no longer permitted.
120110
# https://blog.readthedocs.com/security-update-on-incoming-webhooks/.
121-
now = timezone.now()
122-
deprecation_date = datetime.datetime(2024, 1, 31, tzinfo=datetime.timezone.utc)
123-
is_deprecated = now >= deprecation_date
124-
if is_deprecated and not self.has_secret():
111+
if not self.has_secret():
125112
return Response(
126113
{"detail": self.missing_secret_deprecated_msg},
127114
status=HTTP_400_BAD_REQUEST,
@@ -418,12 +405,9 @@ def is_payload_valid(self):
418405
See https://developer.github.com/webhooks/securing/.
419406
"""
420407
signature = self.request.headers.get(GITHUB_SIGNATURE_HEADER)
421-
secret = self.get_integration().secret
422-
if not secret:
423-
log.debug('Skipping payload signature validation.')
424-
return True
425408
if not signature:
426409
return False
410+
secret = self.get_integration().secret
427411
msg = self.request.body.decode()
428412
digest = WebhookMixin.get_digest(secret, msg)
429413
result = hmac.compare_digest(
@@ -492,13 +476,6 @@ def handle_webhook(self):
492476

493477
# Handle pull request events.
494478
if self.project.external_builds_enabled and event == GITHUB_PULL_REQUEST:
495-
# Requests from anonymous users are ignored.
496-
if not integration.secret:
497-
return Response(
498-
{"detail": self.missing_secret_for_pr_events_msg},
499-
status=HTTP_400_BAD_REQUEST,
500-
)
501-
502479
if action in [
503480
GITHUB_PULL_REQUEST_OPENED,
504481
GITHUB_PULL_REQUEST_REOPENED,
@@ -598,10 +575,9 @@ def is_payload_valid(self):
598575
See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#secret-token.
599576
"""
600577
token = self.request.headers.get(GITLAB_TOKEN_HEADER, "")
578+
if not token:
579+
return False
601580
secret = self.get_integration().secret
602-
if not secret:
603-
log.debug('Skipping payload signature validation.')
604-
return True
605581
return constant_time_compare(secret, token)
606582

607583
def get_external_version_data(self):
@@ -636,8 +612,6 @@ def handle_webhook(self):
636612
event=event,
637613
)
638614

639-
integration = self.get_integration()
640-
641615
# Always update `latest` branch to point to the default branch in the repository
642616
# even if the event is not gonna be handled. This helps us to keep our db in sync.
643617
default_branch = self.data.get("project", {}).get("default_branch", None)
@@ -665,12 +639,6 @@ def handle_webhook(self):
665639
raise ParseError('Parameter "ref" is required') from exc
666640

667641
if self.project.external_builds_enabled and event == GITLAB_MERGE_REQUEST:
668-
if not integration.secret:
669-
return Response(
670-
{"detail": self.missing_secret_for_pr_events_msg},
671-
status=HTTP_400_BAD_REQUEST,
672-
)
673-
674642
if action in [
675643
GITLAB_MERGE_REQUEST_OPEN,
676644
GITLAB_MERGE_REQUEST_REOPEN,
@@ -779,12 +747,9 @@ def is_payload_valid(self):
779747
See https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/#Secure-webhooks.
780748
"""
781749
signature = self.request.headers.get(BITBUCKET_SIGNATURE_HEADER)
782-
secret = self.get_integration().secret
783-
if not secret:
784-
log.debug("Skipping payload signature validation.")
785-
return True
786750
if not signature:
787751
return False
752+
secret = self.get_integration().secret
788753
msg = self.request.body.decode()
789754
digest = WebhookMixin.get_digest(secret, msg)
790755
result = hmac.compare_digest(

readthedocs/rtd_tests/tests/test_api.py

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,25 +2287,6 @@ def test_github_empty_signature(self, trigger_build):
22872287
GitHubWebhookView.invalid_payload_msg
22882288
)
22892289

2290-
def test_github_skip_signature_validation(self, trigger_build):
2291-
client = APIClient()
2292-
payload = '{"ref":"master"}'
2293-
Integration.objects.filter(pk=self.github_integration.pk).update(secret=None)
2294-
headers = {
2295-
GITHUB_EVENT_HEADER: GITHUB_PUSH,
2296-
GITHUB_SIGNATURE_HEADER: 'skipped',
2297-
}
2298-
resp = client.post(
2299-
reverse(
2300-
'api_webhook_github',
2301-
kwargs={'project_slug': self.project.slug}
2302-
),
2303-
json.loads(payload),
2304-
format='json',
2305-
headers=headers,
2306-
)
2307-
self.assertEqual(resp.status_code, 200)
2308-
23092290
@mock.patch('readthedocs.core.views.hooks.sync_repository_task', mock.MagicMock())
23102291
def test_github_sync_on_push_event(self, trigger_build):
23112292
"""Sync if the webhook doesn't have the create/delete events, but we receive a push event with created/deleted."""
@@ -2647,23 +2628,6 @@ def test_gitlab_empty_token(self, trigger_build):
26472628
GitLabWebhookView.invalid_payload_msg
26482629
)
26492630

2650-
def test_gitlab_skip_token_validation(self, trigger_build):
2651-
client = APIClient()
2652-
Integration.objects.filter(pk=self.gitlab_integration.pk).update(secret=None)
2653-
headers = {
2654-
GITLAB_TOKEN_HEADER: 'skipped',
2655-
}
2656-
resp = client.post(
2657-
reverse(
2658-
'api_webhook_gitlab',
2659-
kwargs={'project_slug': self.project.slug}
2660-
),
2661-
{'object_kind': 'pull_request'},
2662-
format='json',
2663-
headers=headers,
2664-
)
2665-
self.assertEqual(resp.status_code, 200)
2666-
26672631
@mock.patch('readthedocs.core.utils.trigger_build')
26682632
def test_gitlab_merge_request_open_event(self, trigger_build, core_trigger_build):
26692633
client = APIClient()
@@ -3248,9 +3212,7 @@ def test_webhook_build_another_branch(self, trigger_build):
32483212
self.assertTrue(resp.data['build_triggered'])
32493213
self.assertEqual(resp.data['versions'], ['v1.0'])
32503214

3251-
@mock.patch("readthedocs.api.v2.views.integrations.timezone.now")
3252-
def test_deprecate_webhooks_without_a_secret(self, now, trigger_build):
3253-
now.return_value = datetime.datetime(2024, 1, 31, tzinfo=datetime.timezone.utc)
3215+
def test_dont_allow_webhooks_without_a_secret(self, trigger_build):
32543216
client = APIClient()
32553217

32563218
Integration.objects.filter(pk=self.github_integration.pk).update(secret=None)

0 commit comments

Comments
 (0)