mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[IMP] runbot: secure webhook with secrets
Use github secret and header verification to validate that the sender is indeed github.
This commit is contained in:
parent
34a92ac0cb
commit
78d00aee38
@ -3,6 +3,10 @@
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@ -10,12 +14,34 @@ from odoo.http import request
|
|||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload_body, remote, signature_header):
|
||||||
|
"""Verify that the payload was sent from GitHub by validating SHA256.
|
||||||
|
|
||||||
|
Raise and return 403 if not authorized.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload_body: original request body to verify (request.body())
|
||||||
|
remote: runbot.remote
|
||||||
|
signature_header: header received from GitHub (x-hub-signature-256)
|
||||||
|
"""
|
||||||
|
if not remote.webhook_secret:
|
||||||
|
return
|
||||||
|
if not signature_header:
|
||||||
|
_logger.info('Received payload without signature header')
|
||||||
|
raise BadRequest(description="x-hub-signature-256 header is missing!")
|
||||||
|
hash_object = hmac.new(remote.webhook_secret.encode('utf-8'), msg=payload_body, digestmod=hashlib.sha256)
|
||||||
|
expected_signature = "sha256=" + hash_object.hexdigest()
|
||||||
|
if not hmac.compare_digest(expected_signature, signature_header):
|
||||||
|
_logger.info('Received payload with invalid signature for remote %s', remote.name)
|
||||||
|
raise BadRequest(description="Request signatures didn't match!")
|
||||||
|
|
||||||
class Hook(http.Controller):
|
class Hook(http.Controller):
|
||||||
|
|
||||||
@http.route(['/runbot/hook', '/runbot/hook/<int:remote_id>'], type='http', auth="public", website=True, csrf=False, sitemap=False)
|
@http.route(['/runbot/hook', '/runbot/hook/<int:remote_id>'], type='http', auth="public", website=True, csrf=False, sitemap=False)
|
||||||
def hook(self, remote_id=None, **_post):
|
def hook(self, remote_id=None, **_post):
|
||||||
event = request.httprequest.headers.get("X-Github-Event")
|
event = request.httprequest.headers.get("X-Github-Event")
|
||||||
payload = json.loads(request.params.get('payload', '{}'))
|
payload_str = request.params.get('payload', '{}')
|
||||||
|
payload = json.loads(payload_str)
|
||||||
if remote_id is None:
|
if remote_id is None:
|
||||||
repo_data = payload.get('repository')
|
repo_data = payload.get('repository')
|
||||||
if repo_data:
|
if repo_data:
|
||||||
@ -31,7 +57,13 @@ class Hook(http.Controller):
|
|||||||
remote_id = remote.id
|
remote_id = remote.id
|
||||||
if not remote_id:
|
if not remote_id:
|
||||||
_logger.error("Remote %s not found", repo_data['ssh_url'])
|
_logger.error("Remote %s not found", repo_data['ssh_url'])
|
||||||
remote = request.env['runbot.remote'].sudo().browse(remote_id)
|
remote = request.env['runbot.remote'].sudo().browse(remote_id).exists()
|
||||||
|
if not remote:
|
||||||
|
raise BadRequest(description='Invalid remote')
|
||||||
|
verify_signature(
|
||||||
|
payload_str.encode('utf-8'), remote.webhook_secret,
|
||||||
|
request.httprequest.headers.get('X-Hub-Signature-256')
|
||||||
|
)
|
||||||
|
|
||||||
# force update of dependencies too in case a hook is lost
|
# force update of dependencies too in case a hook is lost
|
||||||
if not payload or event == 'push':
|
if not payload or event == 'push':
|
||||||
|
@ -220,6 +220,7 @@ class Remote(models.Model):
|
|||||||
send_status = fields.Boolean('Send status', default=False, tracking=True)
|
send_status = fields.Boolean('Send status', default=False, tracking=True)
|
||||||
|
|
||||||
token = fields.Char("Github token", groups="runbot.group_runbot_admin")
|
token = fields.Char("Github token", groups="runbot.group_runbot_admin")
|
||||||
|
webhook_secret = fields.Char("Webhook secret", groups="runbot.group_runbot_admin")
|
||||||
|
|
||||||
@api.depends('name')
|
@api.depends('name')
|
||||||
def _compute_base_infos(self):
|
def _compute_base_infos(self):
|
||||||
|
@ -3,15 +3,20 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from unittest import skip
|
from unittest import skip
|
||||||
from unittest.mock import patch, Mock
|
from unittest.mock import patch, Mock
|
||||||
from subprocess import CalledProcessError
|
from subprocess import CalledProcessError
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from odoo import fields
|
from odoo import fields
|
||||||
from odoo.tests import common, TransactionCase
|
from odoo.tests import common, TransactionCase
|
||||||
from odoo.tools import mute_logger
|
from odoo.tools import mute_logger
|
||||||
|
|
||||||
|
from odoo.addons.runbot.controllers.hook import verify_signature
|
||||||
|
|
||||||
from .common import RunbotCase, RunbotCaseMinimalSetup
|
from .common import RunbotCase, RunbotCaseMinimalSetup
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@ -364,6 +369,31 @@ class TestGithub(TransactionCase):
|
|||||||
|
|
||||||
self.assertEqual(2, mock_session.return_value.post.call_count, "_github method should try two times by default")
|
self.assertEqual(2, mock_session.return_value.post.call_count, "_github method should try two times by default")
|
||||||
|
|
||||||
|
def test_verify_signature(self):
|
||||||
|
project = self.env['runbot.project'].create({'name': 'Tests'})
|
||||||
|
repo_server = self.env['runbot.repo'].create({
|
||||||
|
'name': 'server',
|
||||||
|
'project_id': project.id,
|
||||||
|
})
|
||||||
|
remote = self.env['runbot.remote'].create({
|
||||||
|
'name': 'bla@example.com:base/server',
|
||||||
|
'repo_id': repo_server.id,
|
||||||
|
})
|
||||||
|
# Should not raise -> no secret on remote
|
||||||
|
payload_body = '{"payload": "data"}'.encode('utf-8')
|
||||||
|
verify_signature(payload_body, remote, '')
|
||||||
|
verify_signature(payload_body, remote, None)
|
||||||
|
# Should not raise, valid signature
|
||||||
|
remote.webhook_secret = 'IAMRUNBOT'
|
||||||
|
signature_header = f'sha256={hmac.new(remote.webhook_secret.encode("utf-8"), msg=payload_body, digestmod=hashlib.sha256).hexdigest()}'
|
||||||
|
verify_signature(payload_body, remote, signature_header)
|
||||||
|
# Should raise invalid signature
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
verify_signature(payload_body, remote, 'invalid header')
|
||||||
|
# Should raise if no signature and webhook_secret is set
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
verify_signature(payload_body, remote, None)
|
||||||
|
|
||||||
|
|
||||||
class TestFetch(RunbotCase):
|
class TestFetch(RunbotCase):
|
||||||
|
|
||||||
|
@ -179,6 +179,7 @@
|
|||||||
<field name="fetch_pull" string="PR"/>
|
<field name="fetch_pull" string="PR"/>
|
||||||
<field name="send_status"/>
|
<field name="send_status"/>
|
||||||
<field name="token" password="True"/>
|
<field name="token" password="True"/>
|
||||||
|
<field name="webhook_secret" password="True"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</group>
|
</group>
|
||||||
@ -217,6 +218,7 @@
|
|||||||
<field name="sequence"/>
|
<field name="sequence"/>
|
||||||
<field name="repo_id"/>
|
<field name="repo_id"/>
|
||||||
<field name="token"/>
|
<field name="token"/>
|
||||||
|
<field name="webhook_secret"/>
|
||||||
<field name="fetch_pull"/>
|
<field name="fetch_pull"/>
|
||||||
<field name="fetch_heads"/>
|
<field name="fetch_heads"/>
|
||||||
<field name="send_status"/>
|
<field name="send_status"/>
|
||||||
|
Loading…
Reference in New Issue
Block a user