fix(reports): enforce server-side recipient on chart/dashboard report subscriptions (#38847)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shaitan
2026-05-20 18:36:42 +01:00
committed by GitHub
parent fbffae0444
commit 69adecd6a3
11 changed files with 514 additions and 35 deletions

View File

@@ -14,11 +14,11 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for UpdateReportScheduleCommand.validate() database invariants."""
"""Unit tests for UpdateReportScheduleCommand.validate()."""
from __future__ import annotations
from unittest.mock import Mock
from unittest.mock import Mock, patch
import pytest
from pytest_mock import MockerFixture
@@ -30,7 +30,11 @@ from superset.commands.report.exceptions import (
from superset.commands.report.update import UpdateReportScheduleCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.reports.models import ReportScheduleType, ReportState
from superset.reports.models import (
ReportCreationMethod,
ReportScheduleType,
ReportState,
)
def _make_model(
@@ -38,10 +42,12 @@ def _make_model(
*,
model_type: ReportScheduleType | str,
database_id: int | None,
creation_method: ReportCreationMethod = ReportCreationMethod.ALERTS_REPORTS,
) -> Mock:
model = mocker.Mock()
model.type = model_type
model.database_id = database_id
model.creation_method = creation_method
model.name = "test_schedule"
model.crontab = "0 9 * * *"
model.last_state = "noop"
@@ -257,6 +263,142 @@ def test_report_to_alert_with_db_accepted(mocker: MockerFixture) -> None:
cmd.validate() # should not raise
# --- Recipient enforcement for chart/dashboard reports ---
_PATCH_GET_USER_EMAIL = "superset.commands.report.update.get_user_email"
def test_chart_report_update_recipient_overridden_with_owner_email(
mocker: MockerFixture,
) -> None:
"""Updating recipients on a chart report always locks them to the owner's email."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.CHARTS,
)
_setup_mocks(mocker, model)
data = {
"recipients": [
{
"type": "Email",
"recipient_config_json": {"target": "other@example.com"},
}
]
}
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
recipients = cmd._properties["recipients"]
assert len(recipients) == 1
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
def test_dashboard_report_update_recipient_overridden_with_owner_email(
mocker: MockerFixture,
) -> None:
"""Updating recipients on a dashboard report locks them to the owner's email."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.DASHBOARDS,
)
_setup_mocks(mocker, model)
data = {
"recipients": [
{
"type": "Email",
"recipient_config_json": {"target": "other@example.com"},
}
]
}
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
recipients = cmd._properties["recipients"]
assert len(recipients) == 1
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
def test_alerts_reports_update_recipient_not_overridden(
mocker: MockerFixture,
) -> None:
"""Recipients on admin-created alerts/reports are not modified on update."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.ALERTS_REPORTS,
)
_setup_mocks(mocker, model)
original_recipient = {
"type": "Email",
"recipient_config_json": {"target": "team@example.com"},
}
cmd = UpdateReportScheduleCommand(
model_id=1, data={"recipients": [original_recipient]}
)
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
assert (
cmd._properties["recipients"][0]["recipient_config_json"]["target"]
== "team@example.com"
)
def test_chart_report_update_no_recipients_in_payload_unchanged(
mocker: MockerFixture,
) -> None:
"""If recipients are not in the update payload, nothing is changed."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.CHARTS,
)
_setup_mocks(mocker, model)
cmd = UpdateReportScheduleCommand(model_id=1, data={"name": "new name"})
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
assert "recipients" not in cmd._properties
def test_chart_report_update_no_user_email_raises(mocker: MockerFixture) -> None:
"""Update fails with a validation error when the user has no email address."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.CHARTS,
)
_setup_mocks(mocker, model)
cmd = UpdateReportScheduleCommand(
model_id=1,
data={
"recipients": [
{"type": "Email", "recipient_config_json": {"target": "x@y.com"}}
]
},
)
with patch(_PATCH_GET_USER_EMAIL, return_value=None):
with pytest.raises(ReportScheduleInvalidError) as exc_info:
cmd.validate()
messages = _get_validation_messages(exc_info)
assert "recipients" in messages
# --- Deactivation state reset ---

View File

@@ -19,7 +19,12 @@ import pytest
from marshmallow import ValidationError
from pytest_mock import MockerFixture
from superset.reports.schemas import ReportSchedulePostSchema, ReportSchedulePutSchema
from superset.reports.schemas import (
ReportRecipientSchema,
ReportSchedulePostSchema,
ReportSchedulePutSchema,
ReportScheduleSubscribeSchema,
)
def test_report_post_schema_custom_width_validation(mocker: MockerFixture) -> None:
@@ -77,6 +82,157 @@ def test_report_post_schema_custom_width_validation(mocker: MockerFixture) -> No
}
def test_report_recipient_schema_email_valid() -> None:
"""Valid email target is accepted by the recipient schema."""
schema = ReportRecipientSchema()
result = schema.load(
{
"type": "Email",
"recipient_config_json": {"target": "user@example.com"},
}
)
assert result["recipient_config_json"]["target"] == "user@example.com"
def test_report_recipient_schema_email_invalid_target() -> None:
"""Invalid email address in target field raises a validation error."""
schema = ReportRecipientSchema()
with pytest.raises(ValidationError) as excinfo:
schema.load(
{
"type": "Email",
"recipient_config_json": {"target": "not-an-email"},
}
)
assert "target" in excinfo.value.messages
def test_report_recipient_schema_email_invalid_cc() -> None:
"""Invalid address in ccTarget field raises a validation error."""
schema = ReportRecipientSchema()
with pytest.raises(ValidationError) as excinfo:
schema.load(
{
"type": "Email",
"recipient_config_json": {
"target": "user@example.com",
"ccTarget": "bad-email",
},
}
)
assert "ccTarget" in excinfo.value.messages
def test_report_recipient_schema_email_invalid_bcc() -> None:
"""Invalid address in bccTarget field raises a validation error."""
schema = ReportRecipientSchema()
with pytest.raises(ValidationError) as excinfo:
schema.load(
{
"type": "Email",
"recipient_config_json": {
"target": "user@example.com",
"bccTarget": "not-valid",
},
}
)
assert "bccTarget" in excinfo.value.messages
def test_report_recipient_schema_email_empty_bcc_allowed() -> None:
"""Empty string in bccTarget is accepted (optional field)."""
schema = ReportRecipientSchema()
result = schema.load(
{
"type": "Email",
"recipient_config_json": {
"target": "user@example.com",
"bccTarget": "",
},
}
)
assert result["recipient_config_json"]["target"] == "user@example.com"
def test_report_recipient_schema_email_empty_cc_allowed() -> None:
"""Empty string in ccTarget is accepted (optional field)."""
schema = ReportRecipientSchema()
result = schema.load(
{
"type": "Email",
"recipient_config_json": {
"target": "user@example.com",
"ccTarget": "",
},
}
)
assert result["recipient_config_json"]["target"] == "user@example.com"
def test_report_recipient_schema_slack_skips_email_validation() -> None:
"""Slack recipients are not validated as email addresses."""
schema = ReportRecipientSchema()
result = schema.load(
{
"type": "Slack",
"recipient_config_json": {"target": "#general"},
}
)
assert result["recipient_config_json"]["target"] == "#general"
def test_subscribe_schema_ignores_excluded_fields(mocker: MockerFixture) -> None:
"""Excluded fields sent by the client are silently dropped, not rejected."""
mocker.patch(
"flask.current_app.config",
{
"ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH": 100,
"ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 2000,
},
)
schema = ReportScheduleSubscribeSchema()
result = schema.load(
{
"type": "Report",
"name": "My subscription",
"crontab": "0 9 * * *",
"timezone": "UTC",
"chart": 1,
# These are excluded server-side — should be silently dropped
"recipients": [
{"type": "Email", "recipient_config_json": {"target": "x@y.com"}}
],
"creation_method": "alerts_reports",
}
)
assert "recipients" not in result
assert "creation_method" not in result
assert "owners" not in result
def test_subscribe_schema_rejects_alert_type(mocker: MockerFixture) -> None:
"""Subscribe endpoint must not allow Alert type — prevents privilege escalation."""
mocker.patch(
"flask.current_app.config",
{
"ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH": 100,
"ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 2000,
},
)
schema = ReportScheduleSubscribeSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"type": "Alert",
"name": "My alert",
"crontab": "0 9 * * *",
"timezone": "UTC",
"chart": 1,
}
)
assert "type" in exc_info.value.messages
MINIMAL_POST_PAYLOAD = {
"type": "Report",
"name": "A report",