# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. import logging from typing import Any from urllib.parse import urlparse import backoff import requests from flask import current_app from superset import feature_flag_manager from superset.reports.models import ReportRecipientType from superset.reports.notifications.base import BaseNotification from superset.reports.notifications.exceptions import ( NotificationParamException, NotificationUnprocessableException, ) from superset.utils import json from superset.utils.decorators import statsd_gauge from superset.utils.network import is_safe_host logger = logging.getLogger(__name__) class WebhookNotification(BaseNotification): """ Sends a post request to a webhook url """ type = ReportRecipientType.WEBHOOK def _get_webhook_url(self) -> str: """ Get the webhook URL from the recipient configuration :returns: The webhook URL :raises NotificationParamException: If the webhook URL is not provided in the recipient configuration """ # noqa: E501 try: cfg = json.loads(self._recipient.recipient_config_json) target = cfg.get("target") if isinstance(cfg, dict) else None if not target: raise NotificationParamException("Webhook URL is required") return target except (json.JSONDecodeError, KeyError, TypeError) as ex: raise NotificationParamException("Webhook URL is required") from ex def _get_req_payload(self) -> dict[str, Any]: header_content = { "notification_format": self._content.header_data.get("notification_format"), "notification_type": self._content.header_data.get("notification_type"), "notification_source": self._content.header_data.get("notification_source"), "chart_id": self._content.header_data.get("chart_id"), "dashboard_id": self._content.header_data.get("dashboard_id"), } content = { "name": self._content.name, "header": header_content, "text": self._content.text, "description": self._content.description, "url": self._content.url, } return content def _get_files(self) -> list[tuple[str, tuple[str, bytes, str]]]: files = [] if self._content.csv: files.append(("files", ("report.csv", self._content.csv, "text/csv"))) if self._content.pdf: files.append( ("files", ("report.pdf", self._content.pdf, "application/pdf")) ) if self._content.screenshots: for i, screenshot in enumerate(self._content.screenshots): files.append( ( "files", (f"screenshot_{i}.png", screenshot, "image/png"), ) ) return files def _validate_webhook_url(self, url: str) -> None: """ Validate the webhook target URL before dispatch. Checks that the scheme is HTTP(S) (and HTTPS when required by config), that a hostname is present, and, unless the operator opts out via ``ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS``, that the host does not resolve to a private/internal address. :raises NotificationParamException: if any of the above checks fail. """ parsed = urlparse(url) scheme = parsed.scheme.lower() if scheme not in ("http", "https"): raise NotificationParamException( "Webhook failed: only HTTP and HTTPS webhook URLs are supported." ) if current_app.config["ALERT_REPORTS_WEBHOOK_HTTPS_ONLY"] and scheme != "https": raise NotificationParamException( "Webhook failed: HTTPS is required by config for webhook URLs." ) if not parsed.hostname: raise NotificationParamException( "Webhook failed: URL must include a valid hostname." ) # Operators with internal webhook targets (chatops bridges, internal # automation, etc.) can opt out of the private-IP block via # ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS. if current_app.config["ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS"]: return if not is_safe_host(parsed.hostname): raise NotificationParamException("Webhook URL target host is not allowed.") @backoff.on_exception( backoff.expo, NotificationUnprocessableException, factor=10, base=2, max_tries=5, # Bound total wall-clock retry time. Without max_time, a hanging or # persistently-failing target can stall a worker for minutes per bad # URL, starving sequential report dispatch. # # backoff (see backoff._sync.retry_exception) samples elapsed at the # start of each attempt and checks it against max_time only after that # attempt fails -- so the giveup decision uses the time measured before # the attempt ran, ignoring the attempt's own duration. With each # request carrying timeout=60, a third attempt can begin past the 120s # mark (its start gated by the prior check, which still saw ~60-70s) and # then run its full 60s before the check trips. The loop therefore makes # 3 attempts: total wall-clock is ~180-210s (180s of requests + up to # ~30s of jitter sleeps: <=10s then <=20s), not 120s. factor is kept at # 10 so legitimately-transient 5xx targets are not abandoned early. max_time=120, ) @statsd_gauge("reports.webhook.send") def send(self) -> None: if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_WEBHOOK"): raise NotificationUnprocessableException( "Attempted to send a Webhook notification but Webhook feature flag \ is not enabled." ) wh_url = self._get_webhook_url() self._validate_webhook_url(wh_url) payload = self._get_req_payload() files = self._get_files() try: if files: data = {} for key, value in payload.items(): if isinstance(value, (dict, list)): data[key] = json.dumps(value) else: data[key] = value response = requests.post( wh_url, data=data, files=files, timeout=60, allow_redirects=False, ) else: response = requests.post( wh_url, json=payload, timeout=60, allow_redirects=False ) logger.info( "Webhook sent to %s, status code: %s", wh_url, response.status_code ) if response.status_code >= 500 or response.status_code == 429: raise NotificationUnprocessableException( f"Webhook failed with status code {response.status_code}: \ {response.text}" ) if response.status_code >= 400: raise NotificationParamException( f"Webhook failed with status code {response.status_code}: \ {response.text}" ) if response.status_code >= 300: # Redirects are intentionally not followed (allow_redirects=False), # so a 3xx means the request never reached the final target. Treat # it as a failure rather than silently reporting success. raise NotificationParamException( f"Webhook returned an unfollowed redirect " f"(status code {response.status_code})" ) except requests.exceptions.RequestException as ex: raise NotificationUnprocessableException(str(ex)) from ex