Skip to content

Commit 2e69037

Browse files
committed
Closes #3952: Add test for webhooks_worker; introduce generate_signature()
1 parent 7b517ab commit 2e69037

File tree

3 files changed

+75
-13
lines changed

3 files changed

+75
-13
lines changed

netbox/extras/tests/test_webhooks.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
import json
2+
import uuid
3+
from unittest.mock import patch
4+
15
import django_rq
26
from django.contrib.contenttypes.models import ContentType
7+
from django.http import HttpResponse
38
from django.urls import reverse
9+
from requests import Session
410
from rest_framework import status
511

612
from dcim.models import Site
713
from extras.choices import ObjectChangeActionChoices
814
from extras.models import Webhook
15+
from extras.webhooks import enqueue_webhooks, generate_signature
16+
from extras.webhooks_worker import process_webhook
917
from utilities.testing import APITestCase
1018

1119

@@ -22,11 +30,13 @@ def setUp(self):
2230
def setUpTestData(cls):
2331

2432
site_ct = ContentType.objects.get_for_model(Site)
25-
PAYLOAD_URL = "http://localhost/"
33+
DUMMY_URL = "http://localhost/"
34+
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
35+
2636
webhooks = Webhook.objects.bulk_create((
27-
Webhook(name='Site Create Webhook', type_create=True, payload_url=PAYLOAD_URL),
28-
Webhook(name='Site Update Webhook', type_update=True, payload_url=PAYLOAD_URL),
29-
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=PAYLOAD_URL),
37+
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}),
38+
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
39+
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
3040
))
3141
for webhook in webhooks:
3242
webhook.obj_type.set([site_ct])
@@ -87,3 +97,47 @@ def test_enqueue_webhook_delete(self):
8797
self.assertEqual(job.args[1]['id'], site.pk)
8898
self.assertEqual(job.args[2], 'site')
8999
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE)
100+
101+
def test_webhooks_worker(self):
102+
103+
request_id = uuid.uuid4()
104+
105+
def dummy_send(_, request):
106+
"""
107+
A dummy implementation of Session.send() to be used for testing.
108+
Always returns a 200 HTTP response.
109+
"""
110+
webhook = Webhook.objects.get(type_create=True)
111+
signature = generate_signature(request.body, webhook.secret)
112+
113+
# Validate the outgoing request headers
114+
self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
115+
self.assertEqual(request.headers['X-Hook-Signature'], signature)
116+
self.assertEqual(request.headers['X-Foo'], 'Bar')
117+
118+
# Validate the outgoing request body
119+
body = json.loads(request.body)
120+
self.assertEqual(body['event'], 'created')
121+
self.assertEqual(body['timestamp'], job.args[4])
122+
self.assertEqual(body['model'], 'site')
123+
self.assertEqual(body['username'], 'testuser')
124+
self.assertEqual(body['request_id'], str(request_id))
125+
self.assertEqual(body['data']['name'], 'Site 1')
126+
127+
return HttpResponse()
128+
129+
# Enqueue a webhook for processing
130+
site = Site.objects.create(name='Site 1', slug='site-1')
131+
enqueue_webhooks(
132+
instance=site,
133+
user=self.user,
134+
request_id=request_id,
135+
action=ObjectChangeActionChoices.ACTION_CREATE
136+
)
137+
138+
# Retrieve the job from queue
139+
job = self.queue.jobs[0]
140+
141+
# Patch the Session object with our dummy_send() method, then process the webhook for sending
142+
with patch.object(Session, 'send', dummy_send) as mock_send:
143+
process_webhook(*job.args)

netbox/extras/webhooks.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import datetime
2+
import hashlib
3+
import hmac
24

35
from django.contrib.contenttypes.models import ContentType
46

@@ -8,6 +10,18 @@
810
from .constants import *
911

1012

13+
def generate_signature(request_body, secret):
14+
"""
15+
Return a cryptographic signature that can be used to verify the authenticity of webhook data.
16+
"""
17+
hmac_prep = hmac.new(
18+
key=secret.encode('utf8'),
19+
msg=request_body.encode('utf8'),
20+
digestmod=hashlib.sha512
21+
)
22+
return hmac_prep.hexdigest()
23+
24+
1125
def enqueue_webhooks(instance, user, request_id, action):
1226
"""
1327
Find Webhook(s) assigned to this instance + action and enqueue them

netbox/extras/webhooks_worker.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import hashlib
2-
import hmac
31
import json
42

53
import requests
64
from django_rq import job
75
from rest_framework.utils.encoders import JSONEncoder
86

97
from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
8+
from .webhooks import generate_signature
109

1110

1211
@job('default')
@@ -43,20 +42,15 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
4342

4443
if webhook.secret != '':
4544
# Sign the request with a hash of the secret key and its content.
46-
hmac_prep = hmac.new(
47-
key=webhook.secret.encode('utf8'),
48-
msg=prepared_request.body.encode('utf8'),
49-
digestmod=hashlib.sha512
50-
)
51-
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
45+
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
5246

5347
with requests.Session() as session:
5448
session.verify = webhook.ssl_verification
5549
if webhook.ca_file_path:
5650
session.verify = webhook.ca_file_path
5751
response = session.send(prepared_request)
5852

59-
if response.status_code >= 200 and response.status_code <= 299:
53+
if 200 <= response.status_code <= 299:
6054
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
6155
else:
6256
raise requests.exceptions.RequestException(

0 commit comments

Comments
 (0)