Skip to content
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

Validate webhook's payload #4940

Merged
merged 53 commits into from
Feb 27, 2019
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ exclude_lines =
raise NotImplementedError
if __name__ == .__main__.:
if getattr(settings, 'DEBUG'):
if six.PY2:
10 changes: 10 additions & 0 deletions docs/webhooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ It might be necessary to re-establish a webhook if you are noticing problems.
To resync a webhook from Read the Docs, visit the integration detail page and
follow the directions for re-syncing your repository webhook.

Payload validation
------------------

If your project was imported through a connected account,
we create a secret for every integration that offers a way to verify that a webhook request is legitimate.
Currently, `GitHub <https://developer.github.com/webhooks/securing/>`__ and `GitLab <https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#secret-token>`__
offer a way to check this.

When :ref:`resyncing the webhook <webhooks:Resyncing webhooks>`, the secret is changed too.

Troubleshooting
---------------

Expand Down
20 changes: 20 additions & 0 deletions readthedocs/integrations/migrations/0004_add_integration_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-12-10 21:28
from __future__ import unicode_literals

from django.db import migrations, models


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=None, help_text='Secret used to validate the payload of the webhook', max_length=255, null=True),
),
]
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-12-10 22:08
from __future__ import unicode_literals

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


class Migration(migrations.Migration):

dependencies = [
('integrations', '0004_add_integration_secret'),
]

operations = [
migrations.AlterField(
model_name='integration',
name='secret',
field=models.CharField(blank=True, default=readthedocs.integrations.utils.get_secret, help_text='Secret used to validate the payload of the webhook', max_length=255, null=True),
),
]
13 changes: 12 additions & 1 deletion readthedocs/integrations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from readthedocs.core.fields import default_token
from readthedocs.projects.models import Project

from .utils import normalize_request_payload
from .utils import get_secret, normalize_request_payload


class HttpExchangeManager(models.Manager):
Expand Down Expand Up @@ -257,12 +257,23 @@ class Integration(models.Model):
'HttpExchange',
related_query_name='integrations',
)
secret = models.CharField(
help_text=_('Secret used to validate the payload of the webhook'),
max_length=255,
blank=True,
null=True,
default=get_secret,
)

objects = IntegrationQuerySet.as_manager()

# Integration attributes
has_sync = False

def recreate_secret(self):
self.secret = get_secret()
self.save(update_fields=['secret'])

def __str__(self):
return (
_('{0} for {1}')
Expand Down
14 changes: 12 additions & 2 deletions readthedocs/integrations/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-

"""Integration utility functions."""

import os


def normalize_request_payload(request):
"""
Expand All @@ -23,3 +23,13 @@ def normalize_request_payload(request):
except AttributeError:
pass
return request_payload


def get_secret(size=64):
stsewd marked this conversation as resolved.
Show resolved Hide resolved
"""
Get a random string of `size` bytes.

:param size: Number of bytes
"""
secret = os.urandom(size)
return secret.hex()
stsewd marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions readthedocs/oauth/services/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from readthedocs.builds import utils as build_utils
from readthedocs.integrations.models import Integration
from readthedocs.integrations.utils import get_secret
from readthedocs.restapi.client import api

from ..models import RemoteOrganization, RemoteRepository
Expand Down Expand Up @@ -178,6 +179,7 @@ def get_webhook_data(self, project, integration):
},
),
),
'secret': integration.secret,
stsewd marked this conversation as resolved.
Show resolved Hide resolved
'content_type': 'json',
},
'events': ['push', 'pull_request', 'create', 'delete'],
Expand Down Expand Up @@ -262,6 +264,7 @@ def update_webhook(self, project, integration):
:rtype: (Bool, Response)
"""
session = self.get_session()
integration.recreate_secret()
data = self.get_webhook_data(project, integration)
url = integration.provider_data.get('url')
resp = None
Expand Down
4 changes: 3 additions & 1 deletion readthedocs/oauth/services/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from readthedocs.builds.utils import get_gitlab_username_repo
from readthedocs.integrations.models import Integration
from readthedocs.integrations.utils import get_secret
from readthedocs.projects.models import Project

from ..models import RemoteOrganization, RemoteRepository
Expand Down Expand Up @@ -252,6 +253,7 @@ def get_webhook_data(self, repo_id, project, integration):
},
),
),
'token': integration.secret,

# Optional
'issues_events': False,
Expand All @@ -275,7 +277,6 @@ def setup_webhook(self, project):
project=project,
integration_type=Integration.GITLAB_WEBHOOK,
)

repo_id = self._get_repo_id(project)
if repo_id is None:
return (False, None)
Expand Down Expand Up @@ -333,6 +334,7 @@ def update_webhook(self, project, integration):
if repo_id is None:
return (False, None)

integration.recreate_secret()
data = self.get_webhook_data(repo_id, project, integration)
hook_id = integration.provider_data.get('id')
resp = None
Expand Down
5 changes: 4 additions & 1 deletion readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ class IntegrationForm(forms.ModelForm):

class Meta:
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 All @@ -775,6 +775,9 @@ def clean_project(self):

def save(self, commit=True):
self.instance = Integration.objects.subclass(self.instance)
# We don't set the secret on the integration
# when it's created via the form.
self.instance.secret = None
return super().save(commit)


Expand Down
Loading