mirror of
https://github.com/apache/superset.git
synced 2026-09-01 04:51:23 +00:00
Co-authored-by: Superset Dev <dev@superset.apache.org> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
711 lines
25 KiB
Python
711 lines
25 KiB
Python
# 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 pandas as pd
|
|
import pytest
|
|
from freezegun import freeze_time
|
|
|
|
from superset.reports.notifications.exceptions import (
|
|
NotificationParamException,
|
|
NotificationUnprocessableException,
|
|
)
|
|
from superset.reports.notifications.webhook import WebhookNotification
|
|
from superset.utils.core import HeaderDataType
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_header_data() -> HeaderDataType:
|
|
return {
|
|
"notification_format": "PNG",
|
|
"notification_type": "Alert",
|
|
"editors": [1],
|
|
"notification_source": None,
|
|
"chart_id": None,
|
|
"dashboard_id": None,
|
|
"slack_channels": None,
|
|
"execution_id": "test-execution-id",
|
|
}
|
|
|
|
|
|
def test_get_webhook_url(mock_header_data) -> None:
|
|
"""
|
|
Test the _get_webhook_url function to ensure it correctly extracts
|
|
the webhook URL from recipient configuration
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
embedded_data=pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}),
|
|
description="Test description",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://example.com/webhook"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
result = webhook_notification._get_webhook_url()
|
|
|
|
assert result == "https://example.com/webhook"
|
|
|
|
|
|
def test_get_webhook_url_missing_url(mock_header_data) -> None:
|
|
"""
|
|
Test that _get_webhook_url raises an exception when URL is missing
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
description="Test description",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json="{}",
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
with pytest.raises(NotificationParamException, match="Webhook URL is required"):
|
|
webhook_notification._get_webhook_url()
|
|
|
|
|
|
def test_get_req_payload_basic(mock_header_data) -> None:
|
|
"""
|
|
Test that _get_req_payload returns correct payload structure
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="Payload Name",
|
|
header_data=mock_header_data,
|
|
embedded_data=None,
|
|
description="Payload Description",
|
|
url="http://example.com/report",
|
|
text="Report Text",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://webhook.com"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
payload = webhook_notification._get_req_payload()
|
|
|
|
assert payload["name"] == "Payload Name"
|
|
assert payload["description"] == "Payload Description"
|
|
assert payload["url"] == "http://example.com/report"
|
|
assert payload["text"] == "Report Text"
|
|
assert isinstance(payload["header"], dict)
|
|
# Optional fields from header_data
|
|
assert payload["header"]["notification_format"] == "PNG"
|
|
assert payload["header"]["notification_type"] == "Alert"
|
|
|
|
|
|
def test_get_files_includes_all_content_types(mock_header_data) -> None:
|
|
"""
|
|
Test that _get_files correctly includes csv, pdf, and multiple screenshot attachments
|
|
""" # noqa: E501
|
|
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
csv_bytes = b"col1,col2\n1,2"
|
|
pdf_bytes = b"%PDF-1.4"
|
|
screenshots = [b"fakeimg1", b"fakeimg2"]
|
|
|
|
content = NotificationContent(
|
|
name="file test",
|
|
header_data=mock_header_data,
|
|
csv=csv_bytes,
|
|
pdf=pdf_bytes,
|
|
screenshots=screenshots,
|
|
description="files test",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://webhook.com"}',
|
|
),
|
|
content=content,
|
|
)
|
|
files = webhook_notification._get_files()
|
|
# There should be 1 csv, 1 pdf, and 2 screenshots = 4 files total
|
|
assert len(files) == 4
|
|
|
|
file_names = [file_info[1][0] for file_info in files]
|
|
assert "report.csv" in file_names
|
|
assert "report.pdf" in file_names
|
|
assert "screenshot_0.png" in file_names
|
|
assert "screenshot_1.png" in file_names
|
|
|
|
mime_types = [file_info[1][2] for file_info in files]
|
|
assert "text/csv" in mime_types
|
|
assert "application/pdf" in mime_types
|
|
assert mime_types.count("image/png") == 2
|
|
|
|
|
|
def test_get_files_includes_xlsx(mock_header_data: HeaderDataType) -> None:
|
|
"""_get_files attaches xlsx bytes as report.xlsx with the spreadsheet MIME type."""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
xlsx_bytes: bytes = b"PK\x03\x04 mock xlsx bytes"
|
|
|
|
content = NotificationContent(
|
|
name="file test",
|
|
header_data=mock_header_data,
|
|
xlsx=xlsx_bytes,
|
|
description="xlsx files test",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://webhook.com"}',
|
|
),
|
|
content=content,
|
|
)
|
|
files = webhook_notification._get_files()
|
|
|
|
assert len(files) == 1
|
|
file_name, file_bytes, mime_type = files[0][1]
|
|
assert file_name == "report.xlsx"
|
|
assert file_bytes == xlsx_bytes
|
|
assert mime_type == (
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
)
|
|
|
|
|
|
def test_get_files_empty_when_no_content(mock_header_data) -> None:
|
|
"""
|
|
Test that _get_files returns empty list when no files present
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="no files",
|
|
header_data=mock_header_data,
|
|
description="no files test",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://webhook.com"}',
|
|
),
|
|
content=content,
|
|
)
|
|
files = webhook_notification._get_files()
|
|
assert files == []
|
|
|
|
|
|
def test_send_http_only_https_check(monkeypatch, mock_header_data) -> None:
|
|
"""
|
|
Test send raises when URL is not HTTPS and config enforces HTTPS only
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert", header_data=mock_header_data, description="Test description"
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "http://notsecure.com/webhook"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
class MockCurrentApp:
|
|
config = {"ALERT_REPORTS_WEBHOOK_HTTPS_ONLY": True}
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", MockCurrentApp
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
|
|
with pytest.raises(NotificationParamException, match="HTTPS is required by config"):
|
|
webhook_notification.send()
|
|
|
|
|
|
def test_send_treats_redirect_as_failure(monkeypatch, mock_header_data) -> None:
|
|
"""
|
|
A 3xx response is a failure: redirects are not followed
|
|
(allow_redirects=False), so the request never reached the final target and
|
|
must not be reported as success.
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert", header_data=mock_header_data, description="Test description"
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://example.com/webhook"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
class MockCurrentApp:
|
|
config = {
|
|
"ALERT_REPORTS_WEBHOOK_HTTPS_ONLY": True,
|
|
"ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS": True,
|
|
"ALERT_REPORTS_WEBHOOK_TIMEOUT": 60,
|
|
}
|
|
|
|
class MockResponse:
|
|
status_code = 302
|
|
text = ""
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", MockCurrentApp
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.requests.post",
|
|
lambda *args, **kwargs: MockResponse(),
|
|
)
|
|
|
|
with pytest.raises(NotificationParamException, match="redirect"):
|
|
webhook_notification.send()
|
|
|
|
|
|
def test_send_forwards_configured_timeout(monkeypatch, mock_header_data) -> None:
|
|
"""
|
|
send() forwards ALERT_REPORTS_WEBHOOK_TIMEOUT to requests.post so the
|
|
call can't hang forever if the webhook target is unreachable.
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert", header_data=mock_header_data, description="Test description"
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://example.com/webhook"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
class MockCurrentApp:
|
|
config = {
|
|
"ALERT_REPORTS_WEBHOOK_HTTPS_ONLY": True,
|
|
"ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS": True,
|
|
"ALERT_REPORTS_WEBHOOK_TIMEOUT": 45,
|
|
}
|
|
|
|
class MockResponse:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
captured_kwargs: dict[str, object] = {}
|
|
|
|
def fake_post(*args, **kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
return MockResponse()
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", MockCurrentApp
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.requests.post", fake_post
|
|
)
|
|
|
|
webhook_notification.send()
|
|
|
|
assert captured_kwargs["timeout"] == 45
|
|
|
|
|
|
def _make_webhook(mock_header_data) -> WebhookNotification:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert", header_data=mock_header_data, description="Test description"
|
|
)
|
|
return WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json='{"target": "https://example.com/webhook"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
|
|
class _MockServerErrorResponse:
|
|
status_code = 500
|
|
text = ""
|
|
|
|
|
|
def _allow_internal_app() -> type:
|
|
class MockCurrentApp:
|
|
config = {
|
|
"ALERT_REPORTS_WEBHOOK_HTTPS_ONLY": True,
|
|
"ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS": True,
|
|
"ALERT_REPORTS_WEBHOOK_TIMEOUT": 60,
|
|
}
|
|
|
|
return MockCurrentApp
|
|
|
|
|
|
def test_send_backoff_bounded_by_max_time(monkeypatch, mock_header_data) -> None:
|
|
"""
|
|
A persistently failing (500) target gives up on wall-time (``max_time``),
|
|
not just ``max_tries``. ``freeze_time(auto_tick_seconds=30)`` advances the
|
|
clock on every time access (backoff measures elapsed via ``datetime.now``,
|
|
which freezegun freezes and ticks), so cumulative elapsed crosses ``max_time=120``
|
|
before ``max_tries=5`` is exhausted. We assert the discriminating *property*
|
|
— gave up on wall-time strictly before exhausting ``max_tries`` — rather than
|
|
a pinned count, because freezegun's per-call tick count is an opaque
|
|
implementation detail. If ``max_time`` is removed this rises to the full 5
|
|
(RED); the flat-clock companion test below anchors that 5 is the ceiling.
|
|
The terminal exception type is unchanged on giveup.
|
|
"""
|
|
webhook_notification = _make_webhook(mock_header_data)
|
|
post_calls: list[int] = []
|
|
|
|
def fake_post(*args, **kwargs) -> _MockServerErrorResponse:
|
|
post_calls.append(1)
|
|
return _MockServerErrorResponse()
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", _allow_internal_app()
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.requests.post", fake_post
|
|
)
|
|
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
|
|
|
|
with freeze_time("2020-01-01", auto_tick_seconds=30):
|
|
with pytest.raises(NotificationUnprocessableException):
|
|
webhook_notification.send()
|
|
|
|
# 1 < count < max_tries(5): wall-time bound fired before tries exhausted.
|
|
assert 1 < len(post_calls) < 5
|
|
|
|
|
|
def test_send_flat_clock_falls_back_to_max_tries(monkeypatch, mock_header_data) -> None:
|
|
"""
|
|
Characterization (NOT a RED discriminator): with the clock held flat,
|
|
``max_time`` can never fire, so ``max_tries=5`` governs and exactly 5 POSTs
|
|
happen. Passes on both buggy and fixed code; its job is to prove the 3-vs-5
|
|
delta in ``test_send_backoff_bounded_by_max_time`` is attributable to
|
|
wall-time, not to ``max_tries``.
|
|
"""
|
|
webhook_notification = _make_webhook(mock_header_data)
|
|
post_calls: list[int] = []
|
|
|
|
def fake_post(*args, **kwargs) -> _MockServerErrorResponse:
|
|
post_calls.append(1)
|
|
return _MockServerErrorResponse()
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", _allow_internal_app()
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.requests.post", fake_post
|
|
)
|
|
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
|
|
|
|
with freeze_time("2020-01-01"):
|
|
with pytest.raises(NotificationUnprocessableException):
|
|
webhook_notification.send()
|
|
|
|
assert len(post_calls) == 5
|
|
|
|
|
|
def test_send_max_time_does_not_abandon_recovering_target(
|
|
monkeypatch, mock_header_data
|
|
) -> None:
|
|
"""
|
|
No-regression guard: a target that fails twice (500) then succeeds on the
|
|
3rd attempt still succeeds, and ``max_time=120`` is not set so low it
|
|
abandons recovery. The clock advances a small +5s per access so ``max_time``
|
|
is genuinely engaged (cumulative elapsed across 3 attempts stays comfortably
|
|
under 120), unlike a flat clock which would pin elapsed at 0 and let the test
|
|
pass at any ``max_time`` — including a misconfigured ``max_time=1``. Lower
|
|
``max_time`` enough and this test correctly fails.
|
|
"""
|
|
webhook_notification = _make_webhook(mock_header_data)
|
|
post_calls: list[int] = []
|
|
|
|
class _OkResponse:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def fake_post(*args, **kwargs):
|
|
post_calls.append(1)
|
|
if len(post_calls) < 3:
|
|
return _MockServerErrorResponse()
|
|
return _OkResponse()
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", _allow_internal_app()
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.requests.post", fake_post
|
|
)
|
|
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
|
|
|
|
with freeze_time("2020-01-01", auto_tick_seconds=5):
|
|
webhook_notification.send()
|
|
|
|
# The clock advances (+5s/access) so ``max_time`` is genuinely engaged, yet
|
|
# cumulative elapsed across 3 attempts stays well under 120: the recovering
|
|
# target (``fake_post`` succeeds on the 3rd) is not abandoned. A flat clock
|
|
# would pin elapsed at 0 and pass at any ``max_time``, including a broken
|
|
# ``max_time=1`` — this non-zero tick keeps the guard real.
|
|
assert len(post_calls) == 3
|
|
|
|
|
|
def test_peer_validating_connection_blocks_rebound_peer() -> None:
|
|
"""
|
|
The webhook POST validates the connected peer address, so a hostname that
|
|
passes ``is_safe_host`` at validation time and then re-resolves to an
|
|
internal address by the time the connection is opened (DNS rebinding) is
|
|
rejected before any request bytes are sent -- mirrors the equivalent test
|
|
for the dataset-import data-URI fetch path.
|
|
"""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from urllib3.connection import HTTPConnection
|
|
|
|
from superset.reports.notifications.exceptions import NotificationParamException
|
|
from superset.reports.notifications.webhook import _PeerValidatingHTTPConnection
|
|
|
|
sock = MagicMock()
|
|
sock.getpeername.return_value = ("169.254.169.254", 80)
|
|
|
|
with patch.object(
|
|
HTTPConnection, "connect", lambda self: setattr(self, "sock", sock)
|
|
):
|
|
conn = _PeerValidatingHTTPConnection("rebinder.example.com")
|
|
with pytest.raises(NotificationParamException):
|
|
conn.connect()
|
|
|
|
|
|
def test_peer_validating_connection_allows_public_peer() -> None:
|
|
"""A connection whose actual peer resolves to a public address is allowed
|
|
through unmodified."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from urllib3.connection import HTTPConnection
|
|
|
|
from superset.reports.notifications.webhook import _PeerValidatingHTTPConnection
|
|
|
|
sock = MagicMock()
|
|
sock.getpeername.return_value = ("93.184.216.34", 80) # example.com, public
|
|
|
|
with patch.object(
|
|
HTTPConnection, "connect", lambda self: setattr(self, "sock", sock)
|
|
):
|
|
conn = _PeerValidatingHTTPConnection("example.com")
|
|
conn.connect() # should not raise
|
|
|
|
|
|
def test_get_requester_returns_plain_requests_when_internal_hosts_allowed(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""
|
|
When the operator opts into internal webhook targets via
|
|
``ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS``, ``_get_requester`` must
|
|
return the plain ``requests`` module (no peer pinning), preserving the
|
|
documented escape hatch for internal automation targets.
|
|
"""
|
|
import requests
|
|
|
|
from superset.reports.notifications.webhook import _get_requester
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", _allow_internal_app()
|
|
)
|
|
|
|
assert _get_requester() is requests
|
|
|
|
|
|
def test_get_requester_pins_peer_when_internal_hosts_disallowed(monkeypatch) -> None:
|
|
"""
|
|
By default (``ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS`` unset/False),
|
|
``_get_requester`` returns a session whose adapters validate the
|
|
connected peer address rather than the plain ``requests`` module.
|
|
"""
|
|
from superset.reports.notifications.webhook import (
|
|
_get_requester,
|
|
_PeerValidatingHTTPAdapter,
|
|
)
|
|
|
|
class MockCurrentApp:
|
|
config = {"ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS": False}
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", MockCurrentApp
|
|
)
|
|
|
|
requester = _get_requester()
|
|
assert isinstance(requester.get_adapter("http://x/"), _PeerValidatingHTTPAdapter)
|
|
assert isinstance(requester.get_adapter("https://x/"), _PeerValidatingHTTPAdapter)
|
|
|
|
|
|
def test_send_rejects_rebound_peer_despite_passed_hostname_check(
|
|
monkeypatch, mock_header_data
|
|
) -> None:
|
|
"""
|
|
End-to-end regression for the DNS-rebinding TOCTOU: the hostname check in
|
|
``_validate_webhook_url`` runs once, ahead of time, and is simulated here
|
|
as having passed (as it would for a low-TTL record that resolves publicly
|
|
at that moment) via monkeypatching ``is_safe_host``. The actual POST
|
|
still connects to a loopback test server. Before the fix this reaches the
|
|
server and succeeds; after the fix the connected peer is validated
|
|
independently and the request is rejected regardless of what the
|
|
hostname check concluded earlier.
|
|
"""
|
|
import http.server
|
|
import threading
|
|
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def do_POST(self) -> None: # noqa: N802
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(b"ok")
|
|
|
|
def log_message(self, *_args: object) -> None:
|
|
pass
|
|
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
|
port = server.server_port
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
description="Test description",
|
|
)
|
|
webhook_notification = WebhookNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.WEBHOOK,
|
|
recipient_config_json=(f'{{"target": "http://127.0.0.1:{port}/"}}'),
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
class MockCurrentApp:
|
|
config = {
|
|
"ALERT_REPORTS_WEBHOOK_HTTPS_ONLY": False,
|
|
"ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS": False,
|
|
"ALERT_REPORTS_WEBHOOK_TIMEOUT": 5,
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", MockCurrentApp
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager."
|
|
"is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
# Simulate the hostname check having passed at validation time (a
|
|
# low-TTL DNS record resolving publicly at that instant).
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.is_safe_host",
|
|
lambda host: True,
|
|
)
|
|
|
|
with pytest.raises(
|
|
(NotificationParamException, NotificationUnprocessableException)
|
|
):
|
|
webhook_notification.send()
|
|
finally:
|
|
server.shutdown()
|
|
|
|
|
|
def test_send_error_message_omits_response_body(monkeypatch, mock_header_data) -> None:
|
|
"""
|
|
A failing response's body must not be interpolated into the raised
|
|
exception message: that message is persisted verbatim as the report
|
|
execution log's error message and readable via the logs API, which would
|
|
otherwise turn the webhook target into a readback oracle for whatever
|
|
it returns (e.g. a cloud metadata service reached via DNS rebinding).
|
|
"""
|
|
webhook_notification = _make_webhook(mock_header_data)
|
|
leaked_response_content = "internal-metadata-service-response-body"
|
|
|
|
class _LeakyResponse:
|
|
status_code = 502
|
|
text = leaked_response_content
|
|
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.current_app", _allow_internal_app()
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled",
|
|
lambda flag: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"superset.reports.notifications.webhook.requests.post",
|
|
lambda *args, **kwargs: _LeakyResponse(),
|
|
)
|
|
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
|
|
|
|
with pytest.raises(NotificationUnprocessableException) as excinfo:
|
|
webhook_notification.send()
|
|
|
|
assert leaked_response_content not in str(excinfo.value)
|
|
assert "502" in str(excinfo.value)
|