From b994a0be5ccf7ef9f8f76c02b243de73313bf29c Mon Sep 17 00:00:00 2001 From: Adrian Moennich Date: Thu, 28 Sep 2023 12:47:49 +0200 Subject: [PATCH] VC/Zoom: Add webhook validation --- vc_zoom/README.md | 10 +++++-- vc_zoom/indico_vc_zoom/controllers.py | 40 +++++++++++++++++++++++---- vc_zoom/indico_vc_zoom/plugin.py | 5 ++-- vc_zoom/setup.cfg | 2 +- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/vc_zoom/README.md b/vc_zoom/README.md index 8b8be84..ae97fab 100644 --- a/vc_zoom/README.md +++ b/vc_zoom/README.md @@ -11,6 +11,10 @@ ## Changelog +### 3.2.5 + +- Handle webhook validation request and use zoom webhook secret token for request verification + ### 3.2.4 - Adapt to Indico 3.2.6 changes @@ -109,7 +113,9 @@ **URL:** `https://yourserver/api/plugin/zoom/webhook` -(write down the "Verification Token", as you will need it in the plugin configuration below) +Copy the "Secret Token", as you will need it in the plugin configuration below. Note that in order +to actually create the webhook you need to validate the URL, which requires the token to saved in +the Indico plugin configuration. Select the following "Event types": * `Meeting has been updated` @@ -124,7 +130,7 @@ These are the most relevant configuration options: * **Notification email addresses** - Additional e-mails which will receive notifications * **E-mail domains** - List of e-mail domains which can be used for the Zoom API (e.g. `cern.ch`) - * **Webhook token** (optional) - the token which Zoom requests will authenticate with (get it from Zoom Marketplace) + * **Webhook Secret Token** (optional) - the token which Zoom requests will authenticate with (get it from Zoom Marketplace) ### Zoom Server-to-Server OAuth diff --git a/vc_zoom/indico_vc_zoom/controllers.py b/vc_zoom/indico_vc_zoom/controllers.py index 8c34da5..f42b574 100644 --- a/vc_zoom/indico_vc_zoom/controllers.py +++ b/vc_zoom/indico_vc_zoom/controllers.py @@ -5,13 +5,16 @@ # them and/or modify them under the terms of the MIT License; # see the LICENSE file for more details. +import hashlib +import hmac + from flask import flash, jsonify, request, session from flask_pluginengine import current_plugin from marshmallow import EXCLUDE from sqlalchemy.orm.attributes import flag_modified from webargs import fields from webargs.flaskparser import use_kwargs -from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import Forbidden, ServiceUnavailable from indico.core.db import db from indico.core.errors import UserValueError @@ -48,17 +51,44 @@ class RHRoomAlternativeHost(RHVCSystemEventBase): class RHWebhook(RH): CSRF_ENABLED = False + def _is_validation_event(self, event): + return event == 'endpoint.url_validation' + + def _get_hmac(self, data): + webhook_token = current_plugin.settings.get('webhook_token') + if not webhook_token: + current_plugin.logger.warning('Tried to validate Zoom webhook, but no secret token has been configured') + raise ServiceUnavailable('No Zoom Webhook Secret Token configured') + return hmac.new(webhook_token.encode(), data, hashlib.sha256).hexdigest() + def _check_access(self): - token = request.headers.get('Authorization') - expected_token = current_plugin.settings.get('webhook_token') - if not expected_token or not token or token != expected_token: - raise Forbidden + timestamp = request.headers.get('x-zm-request-timestamp') + zoom_signature_header = request.headers.get('x-zm-signature') + signature = self._get_hmac(b'v0:%s:%s' % (timestamp.encode(), request.data)) + expected_header = f'v0={signature}' + if zoom_signature_header != expected_header: + current_plugin.logger.warning('Received request with invalid signature: Expected %s, got %s, payload %s', + expected_header, zoom_signature_header, request.data.decode()) + raise Forbidden('Zoom signature verification failed') @use_kwargs({ 'event': fields.String(required=True), 'payload': fields.Dict(required=True) }, unknown=EXCLUDE) def _process(self, event, payload): + if self._is_validation_event(event): + return self._handle_validation(payload) + return self._handle_zoom_event(event, payload) + + def _handle_validation(self, payload): + plain_token = payload['plainToken'] + signed_token = self._get_hmac(plain_token.encode()) + return jsonify({ + 'plainToken': plain_token, + 'encryptedToken': signed_token + }) + + def _handle_zoom_event(self, event, payload): meeting_id = payload['object']['id'] vc_room = VCRoom.query.filter(VCRoom.data.contains({'zoom_id': meeting_id})).first() diff --git a/vc_zoom/indico_vc_zoom/plugin.py b/vc_zoom/indico_vc_zoom/plugin.py index 9de824c..8898442 100644 --- a/vc_zoom/indico_vc_zoom/plugin.py +++ b/vc_zoom/indico_vc_zoom/plugin.py @@ -58,8 +58,9 @@ class PluginSettingsForm(VCPluginSettingsFormBase): client_id = StringField(_('Client ID'), []) client_secret = IndicoPasswordField(_('Client Secret'), [], toggle=True) - webhook_token = IndicoPasswordField(_('Webhook Token'), toggle=True, - description=_("Specify Zoom's webhook token if you want live updates")) + webhook_token = IndicoPasswordField(_('Webhook Secret Token'), toggle=True, + description=_("Specify the \"Secret Token\" of your Zoom Webhook if you want " + "live updates in case of modified/deleted Zoom meetings.")) user_lookup_mode = IndicoEnumSelectField(_('User lookup mode'), [DataRequired()], enum=UserLookupMode, description=_('Specify how Indico should look up the zoom user that ' diff --git a/vc_zoom/setup.cfg b/vc_zoom/setup.cfg index 6cbe7a8..66a0137 100644 --- a/vc_zoom/setup.cfg +++ b/vc_zoom/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = indico-plugin-vc-zoom -version = 3.2.4 +version = 3.2.5 description = Zoom video-conferencing plugin for Indico long_description = file: README.md long_description_content_type = text/markdown; charset=UTF-8; variant=GFM