diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 52aba763d..94392189d 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,3 +1,4 @@ +import json from collections import OrderedDict from datetime import date @@ -12,6 +13,7 @@ from django.http import HttpResponse from django.template import Template, Context from django.urls import reverse from django.utils.text import slugify +from rest_framework.utils.encoders import JSONEncoder from taggit.models import TagBase, GenericTaggedItemBase from utilities.fields import ColorField @@ -92,8 +94,9 @@ class Webhook(models.Model): ) additional_headers = models.TextField( blank=True, - help_text="User supplied headers which should be added to the request in addition to the HTTP content type. " - "Headers are supplied as key/value pairs in a JSON object." + help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. " + "Headers should be defined in the format Name: Value. Jinja2 template processing is " + "support with the same context as the request body (below)." ) body_template = models.TextField( blank=True, @@ -139,14 +142,29 @@ class Webhook(models.Model): if not self.ssl_verification and self.ca_file_path: raise ValidationError({ - 'ca_file_path': 'Do not specify a CA certificate file if SSL verification is dissabled.' + 'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.' }) - # Verify that JSON data is provided as an object - if self.additional_headers and type(self.additional_headers) is not dict: - raise ValidationError({ - 'additional_headers': 'Header JSON data must be in object form. Example: {"X-API-KEY": "abc123"}' - }) + def render_headers(self, context): + """ + Render additional_headers and return a dict of Header: Value pairs. + """ + if not self.additional_headers: + return {} + ret = {} + data = render_jinja2(self.additional_headers, context) + for line in data.splitlines(): + header, value = line.split(':') + ret[header.strip()] = value.strip() + return ret + + def render_body(self, context): + if self.body_template: + return render_jinja2(self.body_template, context) + elif self.http_content_type == HTTP_CONTENT_TYPE_JSON: + return json.dumps(context, cls=JSONEncoder) + else: + return context # diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 026a82bb8..06b4f7c7e 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -34,7 +34,7 @@ class WebhookTest(APITestCase): DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" webhooks = Webhook.objects.bulk_create(( - Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}), + Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), )) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index b91561846..5513915ce 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,14 +1,10 @@ -import json import logging import requests from django_rq import job from jinja2.exceptions import TemplateError -from rest_framework.utils.encoders import JSONEncoder -from utilities.utils import render_jinja2 from .choices import ObjectChangeActionChoices -from .constants import HTTP_CONTENT_TYPE_JSON from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') @@ -28,55 +24,56 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'data': data } - # Build HTTP headers + # Build the headers for the HTTP request headers = { 'Content-Type': webhook.http_content_type, } - if webhook.additional_headers: - headers.update(webhook.additional_headers) + try: + headers.update(webhook.render_headers(context)) + except (TemplateError, ValueError) as e: + logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e)) + raise e + # Render the request body + try: + body = webhook.render_body(context) + except TemplateError as e: + logger.error("Error rendering request body for webhook {}: {}".format(webhook, e)) + raise e + + # Prepare the HTTP request params = { 'method': 'POST', 'url': webhook.payload_url, - 'headers': headers + 'headers': headers, + 'data': body, } - logger.info( "Sending webhook to {}: {} {}".format(params['url'], context['model'], context['event']) ) + logger.debug(params) + try: + prepared_request = requests.Request(**params).prepare() + except requests.exceptions.RequestException as e: + logger.error("Error forming HTTP request: {}".format(e)) + raise e - # Construct the request body. If a template has been defined, use it. Otherwise, dump the context as either JSON - # or form data. - if webhook.body_template: - try: - params['data'] = render_jinja2(webhook.body_template, context) - except TemplateError as e: - logger.error("Error rendering request body: {}".format(e)) - return - elif webhook.http_content_type == HTTP_CONTENT_TYPE_JSON: - params['data'] = json.dumps(context, cls=JSONEncoder) - else: - params['data'] = context - - prepared_request = requests.Request(**params).prepare() - + # If a secret key is defined, sign the request with a hash of the key and its content if webhook.secret != '': - # Sign the request with a hash of the secret key and its content. prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret) + # Send the request with requests.Session() as session: session.verify = webhook.ssl_verification if webhook.ca_file_path: session.verify = webhook.ca_file_path response = session.send(prepared_request) - logger.debug(params) - if 200 <= response.status_code <= 299: logger.info("Request succeeded; response status {}".format(response.status_code)) return 'Status {} returned, webhook successfully processed.'.format(response.status_code) else: - logger.error("Request failed; response status {}: {}".format(response.status_code, response.content)) + logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content)) raise requests.exceptions.RequestException( "Status {} returned with content '{}', webhook FAILED to process.".format( response.status_code, response.content