Skip to content

Validate webhook's payload #4940

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 53 commits into from
Feb 27, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
fe3fa3d
Add secret field to Integration model
stsewd Nov 30, 2018
36e184b
Validate GitHub webhook's payload
stsewd Nov 30, 2018
9393cd8
Docstrings
stsewd Dec 3, 2018
ffd52f2
Add helptext
stsewd Dec 3, 2018
4b6ceab
Merge branch 'master' into validate-payload-webhooks
stsewd Dec 3, 2018
8859740
Backwards compatibility with old webhooks
stsewd Dec 3, 2018
8996499
Use custom parsers
stsewd Dec 3, 2018
0fa97c4
Docstrings
stsewd Dec 3, 2018
29a40a0
Put back normalize_request_payload
stsewd Dec 3, 2018
0855f77
Linter
stsewd Dec 3, 2018
c17868f
Tests
stsewd Dec 3, 2018
b1fea27
Python2 syntax
stsewd Dec 4, 2018
6b4f050
Python 2 again
stsewd Dec 4, 2018
d373f66
Validate GitLab's token
stsewd Dec 4, 2018
e41b656
More python2
stsewd Dec 4, 2018
0e444b9
More tests
stsewd Dec 4, 2018
0c73fed
Remove import
stsewd Dec 4, 2018
ff377cc
Make os.urandom python2 compatible
stsewd Dec 4, 2018
ddaa146
Merge branch 'master' into validate-payload-webhooks
stsewd Dec 4, 2018
e26452a
Keep compatibility with webhooks
stsewd Dec 4, 2018
0663179
Ignore six.PY2 from coverage
stsewd Dec 4, 2018
ed16a4e
Set secret only when rtd creates the webhook
stsewd Dec 4, 2018
3f27c14
Update secret on resync
stsewd Dec 4, 2018
da5e0b0
Merge branch 'master' into validate-payload-webhooks
stsewd Dec 5, 2018
12daf33
Refactor
stsewd Dec 5, 2018
43bad08
Fix
stsewd Dec 5, 2018
ac5750f
Rename
stsewd Dec 5, 2018
216a57f
Refactor
stsewd Dec 10, 2018
ab37b65
Refactor
stsewd Dec 10, 2018
c2c9a1a
Divide migrations
stsewd Dec 10, 2018
e139051
Fix migrations
stsewd Dec 10, 2018
8b57145
i18n
stsewd Dec 10, 2018
8999197
Set secret on webhook creation and update
stsewd Dec 11, 2018
d858acf
Hack django rest framework
stsewd Dec 11, 2018
6a4098e
Move msg to class
stsewd Dec 11, 2018
f124823
Merge branch 'master' into validate-payload-webhooks
stsewd Jan 2, 2019
60a2fc3
Fix merge
stsewd Jan 2, 2019
7e99478
Remove unused imports
stsewd Jan 14, 2019
62a3b51
Log warning
stsewd Jan 14, 2019
af4e4c8
Better comment
stsewd Jan 14, 2019
b2c728f
Change defaults for payload validation
stsewd Jan 14, 2019
92aead5
Merge branch 'master' into validate-payload-webhooks
stsewd Jan 14, 2019
9c01b12
Clean up tests
stsewd Jan 14, 2019
3962741
Linter
stsewd Jan 14, 2019
3ac887e
Use reverse
stsewd Jan 14, 2019
83ca111
Update docs
stsewd Jan 14, 2019
414e44d
Change wording
stsewd Jan 17, 2019
4426f71
Merge branch 'master' into validate-payload-webhooks
stsewd Jan 17, 2019
7ad06e0
Merge branch 'master' into validate-payload-webhooks
stsewd Jan 22, 2019
fbeb7fd
Remove python2 support
stsewd Jan 23, 2019
7eb6005
Merge branch 'master' into validate-payload-webhooks
stsewd Jan 24, 2019
42741a4
Merge branch 'master' into validate-payload-webhooks
stsewd Feb 18, 2019
96c7020
Merge branch 'master' into validate-payload-webhooks
stsewd Feb 26, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions readthedocs/integrations/migrations/0004_add_integration_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-11-29 22:52
from __future__ import unicode_literals

from django.db import migrations, models
import readthedocs.integrations.utils


class Migration(migrations.Migration):

dependencies = [
('integrations', '0003_add_missing_model_change_migrations'),
]

operations = [
migrations.AddField(
model_name='integration',
name='secret',
field=models.CharField(blank=True, default=readthedocs.integrations.utils.get_secret, max_length=255),
),
]
2 changes: 2 additions & 0 deletions readthedocs/integrations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from rest_framework import status

from readthedocs.core.fields import default_token
from readthedocs.integrations.utils import get_secret
from readthedocs.projects.models import Project

from .utils import normalize_request_payload
Expand Down Expand Up @@ -268,6 +269,7 @@ class Integration(models.Model):
'HttpExchange',
related_query_name='integrations',
)
secret = models.CharField(max_length=255, blank=True, default=get_secret)

objects = IntegrationQuerySet.as_manager()

Expand Down
21 changes: 13 additions & 8 deletions readthedocs/integrations/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Integration utility functions."""

from __future__ import division, print_function, unicode_literals

def normalize_request_payload(request):
import json
import os


def normalize_request_payload(request_body, content_type):
"""
Normalize the request body, hopefully to JSON.

Expand All @@ -12,12 +17,12 @@ def normalize_request_payload(request):
:returns: The request body as a string
:rtype: str
"""
request_payload = getattr(request, 'data', {})
if request.content_type != 'application/json':
if content_type != 'application/json':
# Here, request_body can be a dict or a MergeDict. Probably best to
# normalize everything first
try:
request_payload = dict(list(request_payload.items()))
except AttributeError:
pass
return request_payload
raise NotImplementedError
return json.loads(request_body)


def get_secret(size=64):
return os.urandom(size).hex()
1 change: 1 addition & 0 deletions readthedocs/oauth/services/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def get_webhook_data(self, project, integration):
'integration_pk': integration.pk}
)
),
'secret': integration.secret,
'content_type': 'json',
},
'events': ['push', 'pull_request', 'create', 'delete'],
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,7 @@ class IntegrationForm(forms.ModelForm):

class Meta(object):
model = Integration
exclude = ['provider_data', 'exchanges'] # pylint: disable=modelform-uses-exclude
exclude = ['provider_data', 'exchanges', 'secret'] # pylint: disable=modelform-uses-exclude

def __init__(self, *args, **kwargs):
self.project = kwargs.pop('project', None)
Expand Down
31 changes: 29 additions & 2 deletions readthedocs/restapi/views/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
unicode_literals,
)

import hashlib
import hmac
import json
import logging
import re

import six
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from rest_framework.exceptions import NotFound, ParseError
from rest_framework.exceptions import AuthenticationFailed, NotFound, ParseError
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.views import APIView
Expand Down Expand Up @@ -59,6 +61,9 @@ def post(self, request, project_slug):
self.project = self.get_project(slug=project_slug)
except Project.DoesNotExist:
raise NotFound('Project not found')
self.body = self.request.stream.read().decode()
if not self.validate_payload():
raise AuthenticationFailed('Payload not valid')
self.data = self.get_data()
resp = self.handle_webhook()
if resp is None:
Expand All @@ -83,12 +88,18 @@ def finalize_response(self, req, *args, **kwargs):

def get_data(self):
"""Normalize posted data."""
return normalize_request_payload(self.request)
return normalize_request_payload(
self.body,
self.request.content_type
)

def handle_webhook(self):
"""Handle webhook payload."""
raise NotImplementedError

def validate_payload(self):
return True

def get_integration(self):
"""
Get or create an inbound webhook to track webhook requests.
Expand Down Expand Up @@ -178,6 +189,22 @@ def get_data(self):
pass
return super(GitHubWebhookView, self).get_data()

def validate_payload(self):
signature = self.request.META['HTTP_X_HUB_SIGNATURE']
msg = self.body
key = self.get_integration().secret
digest = hmac.new(
key.encode(),
msg=msg.encode(),
digestmod=hashlib.sha1
).hexdigest()
result = hmac.compare_digest(
b'sha1=' + digest.encode(),
signature.encode()
)
log.info('Valid payload? %s', result)
return result

def handle_webhook(self):
# Get event and trigger other webhook events
event = self.request.META.get(GITHUB_EVENT_HEADER, GITHUB_PUSH)
Expand Down