fix(schemas): tighten guest dataset fields, external_url protocols, ssh creds, prophet bounds (#40640)

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-06-09 18:30:30 -07:00
committed by GitHub
co-authored by Claude Code
parent 1bfdb19e88
commit 0a1e51f542
35 changed files with 608 additions and 16 deletions
+43 -4
View File
@@ -62,6 +62,44 @@ def get_time_grain_choices() -> Any:
]
# Fallback upper bound for the number of Prophet forecast periods when the
# application config cannot be read (for example, outside of an app context).
DEFAULT_MAX_PROPHET_PERIODS = 10000
def get_max_prophet_periods() -> int:
"""Get the configured upper bound for Prophet forecast periods."""
try:
configured = current_app.config.get(
"MAX_PROPHET_PERIODS", DEFAULT_MAX_PROPHET_PERIODS
)
except RuntimeError:
# Outside app context, fall back to the default bound
return DEFAULT_MAX_PROPHET_PERIODS
# Normalize to int so that overrides supplied as strings (for example via
# ``os.getenv``) don't cause a TypeError when compared by ``Range``. Fall
# back to the default for invalid or non-positive values.
try:
normalized = int(configured)
except (TypeError, ValueError):
return DEFAULT_MAX_PROPHET_PERIODS
return normalized if normalized > 0 else DEFAULT_MAX_PROPHET_PERIODS
def validate_prophet_periods(value: int) -> None:
"""Ensure the number of Prophet forecast periods stays within bounds."""
max_periods = get_max_prophet_periods()
Range(
min=1,
max=max_periods,
error=_(
"`periods` must be between 1 and %(max)s",
max=max_periods,
),
)(value)
#
# RISON/JSON schemas for query parameters
#
@@ -242,7 +280,7 @@ class ChartPostSchema(Schema):
metadata={"description": certification_details_description}, allow_none=True
)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
uuid = fields.UUID(allow_none=True)
@@ -300,7 +338,7 @@ class ChartPutSchema(Schema):
metadata={"description": certification_details_description}, allow_none=True
)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
tags = fields.List(fields.Integer(metadata={"description": tags_description}))
uuid = fields.UUID(allow_none=True)
@@ -656,8 +694,9 @@ class ChartDataProphetOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
"description": "Time periods (in units of `time_grain`) to predict into "
"the future",
"example": 7,
"min": 0,
"min": 1,
},
validate=validate_prophet_periods,
required=True,
)
confidence_interval = fields.Float(
@@ -1671,7 +1710,7 @@ class ImportV1ChartSchema(Schema):
version = fields.String(required=True)
dataset_uuid = fields.UUID(required=True)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
tags = fields.List(fields.String(), allow_none=True)
+4
View File
@@ -1351,6 +1351,10 @@ MAPBOX_API_KEY = os.environ.get("MAPBOX_API_KEY", "")
# Maximum number of rows returned for any analytical database query
SQL_MAX_ROW = 100000
# Maximum number of forecast periods accepted by the Prophet post-processing
# operation. Bounds resource usage when predicting into the future.
MAX_PROPHET_PERIODS = 10000
# Maximum number of rows for any query with Server Pagination in Table Viz type
TABLE_VIZ_MAX_ROW_SERVER = 500000
+73 -8
View File
@@ -23,6 +23,7 @@ from marshmallow.validate import Length, ValidationError
from superset import security_manager
from superset.tags.models import TagType
from superset.utils import json
from superset.utils.schema import validate_external_url
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
@@ -115,6 +116,51 @@ def validate_json_metadata(value: Union[bytes, bytearray, str]) -> None:
raise ValidationError(errors)
# Patterns for CSS constructs that can be abused to execute scripts or pull in
# remote stylesheets/resources. The custom CSS is stored verbatim and re-served
# into the dashboard page, so these are rejected at validation time. Ordinary
# styling (including ``url(...)`` referencing relative paths or ``data:`` image
# URIs) is left untouched.
_CSS_SCRIPT_SCHEME = r"(?:javascript|vbscript|livescript|mocha)\s*:"
_DANGEROUS_CSS_PATTERNS: tuple[tuple[str, "re.Pattern[str]"], ...] = (
# Legacy IE dynamic expressions, e.g. ``width: expression(alert(1))``.
("expression(", re.compile(r"expression\s*\(", re.IGNORECASE)),
# Inline script schemes anywhere in the declaration.
("script scheme", re.compile(_CSS_SCRIPT_SCHEME, re.IGNORECASE)),
# Remote stylesheet imports.
("@import", re.compile(r"@import\b", re.IGNORECASE)),
# url(...) pointing at a script scheme. Legitimate image/relative/data URLs
# are intentionally not matched here.
(
"url() with script scheme",
re.compile(r"url\(\s*['\"]?\s*" + _CSS_SCRIPT_SCHEME, re.IGNORECASE),
),
)
def validate_css(value: Union[bytes, bytearray, str, None]) -> None:
"""Reject custom dashboard CSS containing known-dangerous constructs.
Lightweight input hardening for the user-supplied ``css`` field, which is
persisted and re-served into the dashboard page. Blocks ``expression(``,
script-scheme URIs (e.g. ``javascript:``), ``@import``, and ``url(...)``
referencing a script scheme, while leaving ordinary styling intact.
CSS escape sequences (e.g. ``\\6a avascript:``) are not expanded before
matching, so this validator is a first-line filter and not a complete XSS
sanitiser; it should not be treated as a substitute for other defences.
"""
if not value:
return
if isinstance(value, (bytes, bytearray)):
text = value.decode("utf-8", errors="ignore")
else:
text = value
for label, pattern in _DANGEROUS_CSS_PATTERNS:
if pattern.search(text):
raise ValidationError(f"CSS contains a disallowed construct ({label}).")
class SharedLabelsColorsField(fields.Field):
"""
A custom field that accepts either a list of strings or a dictionary.
@@ -315,8 +361,19 @@ class DashboardDatasetSchema(Schema):
@post_dump()
def post_dump(self, serialized: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
if security_manager.is_guest_user():
del serialized["owners"]
del serialized["database"]
serialized.pop("owners", None)
serialized.pop("database", None)
# Guest users should never receive fields that expose internal
# connection or query details.
for key in (
"sql",
"select_star",
"perm",
"edit_url",
"fetch_values_predicate",
"template_params",
):
serialized.pop(key, None)
return serialized
@@ -360,7 +417,9 @@ class DashboardPostSchema(BaseDashboardSchema):
position_json = fields.String(
metadata={"description": position_json_description}, validate=validate_json
)
css = fields.String(metadata={"description": css_description})
css = fields.String(
metadata={"description": css_description}, validate=validate_css
)
theme_id = fields.Integer(
metadata={"description": "Theme ID for the dashboard"}, allow_none=True
)
@@ -376,7 +435,7 @@ class DashboardPostSchema(BaseDashboardSchema):
metadata={"description": certification_details_description}, allow_none=True
)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
external_url = fields.String(allow_none=True, validate=validate_external_url)
uuid = fields.UUID(allow_none=True)
@@ -386,7 +445,9 @@ class DashboardCopySchema(Schema):
allow_none=True,
validate=Length(0, 500),
)
css = fields.String(metadata={"description": css_description})
css = fields.String(
metadata={"description": css_description}, validate=validate_css
)
json_metadata = fields.String(
metadata={"description": json_metadata_description},
validate=validate_json_metadata,
@@ -421,7 +482,11 @@ class DashboardPutSchema(BaseDashboardSchema):
allow_none=True,
validate=validate_json,
)
css = fields.String(metadata={"description": css_description}, allow_none=True)
css = fields.String(
metadata={"description": css_description},
allow_none=True,
validate=validate_css,
)
theme_id = fields.Integer(
metadata={"description": "Theme ID for the dashboard"}, allow_none=True
)
@@ -440,7 +505,7 @@ class DashboardPutSchema(BaseDashboardSchema):
metadata={"description": certification_details_description}, allow_none=True
)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
external_url = fields.String(allow_none=True, validate=validate_external_url)
tags = fields.List(
fields.Integer(metadata={"description": tags_description}, allow_none=True)
)
@@ -512,7 +577,7 @@ class ImportV1DashboardSchema(Schema):
metadata = fields.Dict()
version = fields.String(required=True)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
external_url = fields.String(allow_none=True, validate=validate_external_url)
certified_by = fields.String(allow_none=True)
certification_details = fields.String(allow_none=True)
published = fields.Boolean(allow_none=True)
+6 -3
View File
@@ -468,11 +468,14 @@ class DatabaseSSHTunnel(Schema):
username = fields.String()
# Basic Authentication
password = fields.String(required=False)
# Credential fields are load-only: accepted on input but never serialized
# back in responses. Response paths that surface a masked placeholder do so
# explicitly (see SSHTunnel.data and mask_password_info).
password = fields.String(required=False, load_only=True)
# password protected private key authentication
private_key = fields.String(required=False)
private_key_password = fields.String(required=False)
private_key = fields.String(required=False, load_only=True)
private_key_password = fields.String(required=False, load_only=True)
@validates_schema
def validate_authentication(self, data: dict[str, Any], **kwargs: Any) -> None:
@@ -17412,6 +17412,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "خاصية «التشغيل» لكائن ما بعد المعالجة غير محددة"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "لم يتم تثبيت حزمة `prophet`"
@@ -17420,6 +17420,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Propietat `operation` de l'objecte de post processament indefinida"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Paquet `prophet` no instal·lat"
@@ -17365,6 +17365,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "vlastnost `operation` objektu následného zpracování není definována"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "balíček `prophet` není nainstalován"
@@ -17795,6 +17795,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "'operation'-Eigenschaft des Nachbearbeitungsobjekts undefiniert"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Paket 'prophet' nicht installiert"
@@ -15621,6 +15621,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr ""
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr ""
@@ -18131,6 +18131,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Propiedad «operation» del objeto de posprocesamiento no definida"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "El paquete «prophet» no está instalado"
@@ -17343,6 +17343,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "خصوصیت `operation` از شیء پس از پردازش تعریف نشده است"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "بسته `prophet` نصب نشده است"
@@ -30460,6 +30460,10 @@ msgstr "Jälkikäsittelyobjektin `operation`-ominaisuus on määrittelemätön"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk, zh, zh_TW]
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
#, fuzzy
msgid "`prophet` package not installed"
msgstr "`prophet`-pakettia ei ole asennettu"
@@ -17943,6 +17943,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "La propriété « operation » de l'objet de post-traitement est indéfinie"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Paquet « prophet » non installée"
@@ -17015,6 +17015,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr ""
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr ""
@@ -15964,6 +15964,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "後処理オブジェクトの `operation` プロパティが未定義です"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "`prophet` パッケージがインストールされていません"
@@ -16853,6 +16853,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr ""
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr ""
@@ -17002,6 +17002,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Pēcapstrādes objekta `operation` rekvizīts nav definēts"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "`prophet` pakotne nav instalēta"
+4
View File
@@ -15597,6 +15597,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr ""
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr ""
@@ -17341,6 +17341,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Kāore i tautuhia te āhuatanga `operation` o te ahanoa tukatuka-iho"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Kāore i tāutahia te kete `prophet`"
@@ -17613,6 +17613,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "`operation` eigenschap van post processing object ongedefinieerd"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "`prophet` package niet geïnstalleerd"
@@ -18187,6 +18187,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Właściwość `operation` obiektu przetwarzania po jest niezdefiniowana"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Pakiet `prophet` nie jest zainstalowany"
@@ -17258,6 +17258,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr ""
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr ""
@@ -17844,6 +17844,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Propriedade `operation` do objeto de pós-processamento indefinida"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Pacote `prophet` não instalado"
@@ -16988,6 +16988,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Свойство `operation` не определено в объекте постобработки"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Пакет \"prophet\" не установлен"
@@ -17387,6 +17387,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "vlastnosť `operation` objektu následného spracovanie nie je definována"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "balíček `prophet` nie je nainstalován"
@@ -17397,6 +17397,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Lastnost `operation` poprocesirnega objekta ni definirana"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Knjižnica `prophet` ni nameščena"
@@ -30100,6 +30100,10 @@ msgstr "คุณสมบัติ `operation` ของออบเจ็ก
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk, zh, zh_TW]
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
#, fuzzy
msgid "`prophet` package not installed"
msgstr "ไม่ได้ติดตั้งแพ็กเกจ `prophet`"
@@ -16228,6 +16228,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr ""
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr ""
@@ -16957,6 +16957,10 @@ msgstr ""
msgid "`operation` property of post processing object undefined"
msgstr "Властивість `operation` обробленого об’єкта не визначено"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "Пакунок `prophet` не встановлено"
@@ -17196,6 +17196,10 @@ msgstr "如果分组被使用 `count` 表示 COUNT(*)。数值列将与聚合器
msgid "`operation` property of post processing object undefined"
msgstr "后处理必须指定操作类型(`operation`"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "未安装程序包 `prophet`"
@@ -17209,6 +17209,10 @@ msgstr "如果分組被使用 `count` 表示 COUNT(*)。數值列將與聚合器
msgid "`operation` property of post processing object undefined"
msgstr "後處理必須指定操作類型(`operation`"
#, python-format
msgid "`periods` must be between 1 and %(max)s"
msgstr ""
msgid "`prophet` package not installed"
msgstr "未安裝程序包 `prophet`"
+33 -1
View File
@@ -14,12 +14,15 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Union
from typing import Any, Optional, Union
from urllib.parse import urlparse
from marshmallow import validate, ValidationError
from superset.utils import json
ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
class OneOfCaseInsensitive(validate.OneOf):
"""
@@ -51,3 +54,32 @@ def validate_json(value: Union[bytes, bytearray, str]) -> None:
json.validate_json(value)
except json.JSONDecodeError as ex:
raise ValidationError("JSON not valid") from ex
def validate_external_url(value: Optional[str]) -> None:
"""
Validator for externally managed object URLs.
Restricts the accepted URL schemes to ``http`` and ``https`` so that
other schemes (for example ``javascript:``, ``data:`` or ``vbscript:``)
cannot be stored and later rendered by clients. The URL must also be
absolute (include a network location/host) so that malformed values such
as ``https:foo`` are rejected. Empty values are allowed since the field is
optional.
:param value: the URL to validate
:raises ValidationError: if the value uses a disallowed scheme or is not
an absolute URL
"""
if not value:
return
parsed = urlparse(value)
scheme = parsed.scheme.lower()
if scheme not in ALLOWED_URL_SCHEMES:
raise ValidationError(
"URL must use one of the following schemes: "
f"{', '.join(sorted(ALLOWED_URL_SCHEMES))}."
)
if not parsed.netloc:
raise ValidationError("URL must be absolute and include a host.")
+146
View File
@@ -22,6 +22,9 @@ from marshmallow import ValidationError
from superset.charts.schemas import (
ChartDataProphetOptionsSchema,
ChartDataQueryObjectSchema,
ChartPostSchema,
DEFAULT_MAX_PROPHET_PERIODS,
get_max_prophet_periods,
get_time_grain_choices,
)
@@ -152,3 +155,146 @@ def test_time_grain_validation_with_config_addons(app_context: None) -> None:
}
result = schema.load(custom_data)
assert result["time_grain"] == "PT10M"
def test_prophet_periods_within_bound(app_context: None) -> None:
"""Prophet periods within the configured bound are accepted"""
schema = ChartDataProphetOptionsSchema()
result = schema.load(
{
"time_grain": "P1D",
"periods": 7,
"confidence_interval": 0.8,
}
)
assert result["periods"] == 7
def test_prophet_periods_over_max_rejected(app_context: None) -> None:
"""Prophet periods over the configured maximum raise a ValidationError"""
schema = ChartDataProphetOptionsSchema()
over_max = current_app.config.get("MAX_PROPHET_PERIODS", 10000) + 1
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"time_grain": "P1D",
"periods": over_max,
"confidence_interval": 0.8,
}
)
assert "periods" in exc_info.value.messages
def test_prophet_periods_below_min_rejected(app_context: None) -> None:
"""Prophet periods below 1 raise a ValidationError"""
schema = ChartDataProphetOptionsSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"time_grain": "P1D",
"periods": 0,
"confidence_interval": 0.8,
}
)
assert "periods" in exc_info.value.messages
def test_get_max_prophet_periods_coerces_string(app_context: None) -> None:
"""A string override (e.g. from an env var) is coerced to int, not crashed on"""
original = current_app.config.get("MAX_PROPHET_PERIODS")
try:
current_app.config["MAX_PROPHET_PERIODS"] = "500"
assert get_max_prophet_periods() == 500
finally:
if original is None:
current_app.config.pop("MAX_PROPHET_PERIODS", None)
else:
current_app.config["MAX_PROPHET_PERIODS"] = original
def test_get_max_prophet_periods_invalid_falls_back(app_context: None) -> None:
"""Invalid or non-positive overrides fall back to the default bound"""
original = current_app.config.get("MAX_PROPHET_PERIODS")
try:
for bad in ("not-a-number", -1, 0):
current_app.config["MAX_PROPHET_PERIODS"] = bad
assert get_max_prophet_periods() == DEFAULT_MAX_PROPHET_PERIODS
finally:
if original is None:
current_app.config.pop("MAX_PROPHET_PERIODS", None)
else:
current_app.config["MAX_PROPHET_PERIODS"] = original
def test_prophet_periods_with_string_config_validates(app_context: None) -> None:
"""Validation works (no TypeError) when the config bound is a string"""
original = current_app.config.get("MAX_PROPHET_PERIODS")
try:
current_app.config["MAX_PROPHET_PERIODS"] = "10"
schema = ChartDataProphetOptionsSchema()
result = schema.load(
{"time_grain": "P1D", "periods": 7, "confidence_interval": 0.8}
)
assert result["periods"] == 7
with pytest.raises(ValidationError):
schema.load(
{"time_grain": "P1D", "periods": 11, "confidence_interval": 0.8}
)
finally:
if original is None:
current_app.config.pop("MAX_PROPHET_PERIODS", None)
else:
current_app.config["MAX_PROPHET_PERIODS"] = original
def test_chart_external_url_accepts_https(app_context: None) -> None:
"""A valid https external_url is accepted"""
schema = ChartPostSchema()
result = schema.load(
{
"slice_name": "test",
"datasource_id": 1,
"datasource_type": "table",
"external_url": "https://example.com/managed",
}
)
assert result["external_url"] == "https://example.com/managed"
@pytest.mark.parametrize(
"url",
[
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
],
)
def test_chart_external_url_rejects_non_http(app_context: None, url: str) -> None:
"""external_url rejects non-http(s) schemes"""
schema = ChartPostSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"slice_name": "test",
"datasource_id": 1,
"datasource_type": "table",
"external_url": url,
}
)
assert "external_url" in exc_info.value.messages
@pytest.mark.parametrize("url", ["https:foo", "http:bar", "https://", "//evil.com"])
def test_chart_external_url_rejects_non_absolute(app_context: None, url: str) -> None:
"""external_url rejects scheme-only / hostless / scheme-relative values"""
schema = ChartPostSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"slice_name": "test",
"datasource_id": 1,
"datasource_type": "table",
"external_url": url,
}
)
assert "external_url" in exc_info.value.messages
+164
View File
@@ -0,0 +1,164 @@
# 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.
from typing import Any
import pytest
from marshmallow import ValidationError
from pytest_mock import MockerFixture
from superset.dashboards.schemas import (
DashboardCopySchema,
DashboardDatasetSchema,
DashboardPostSchema,
DashboardPutSchema,
)
GUEST_RESTRICTED_FIELDS = [
"owners",
"database",
"sql",
"select_star",
"perm",
"edit_url",
"fetch_values_predicate",
"template_params",
]
def _dataset_payload() -> dict[str, Any]:
return {
"id": 1,
"database": {"id": 1, "name": "test_db"},
"owners": [{"id": 1}],
"sql": "SELECT 1",
"select_star": "SELECT * FROM t",
"perm": "[db].[table]",
"edit_url": "/edit/1",
"fetch_values_predicate": "1 = 1",
"template_params": "{}",
"table_name": "t",
}
def test_dashboard_dataset_guest_filtering(mocker: MockerFixture) -> None:
"""Guest users should not receive sensitive dataset fields."""
mocker.patch(
"superset.dashboards.schemas.security_manager.is_guest_user",
return_value=True,
)
result = DashboardDatasetSchema().dump(_dataset_payload())
for field in GUEST_RESTRICTED_FIELDS:
assert field not in result, f"{field} should be removed for guest users"
assert result["table_name"] == "t"
def test_dashboard_dataset_non_guest_keeps_fields(mocker: MockerFixture) -> None:
"""Non-guest users keep the sensitive dataset fields."""
mocker.patch(
"superset.dashboards.schemas.security_manager.is_guest_user",
return_value=False,
)
result = DashboardDatasetSchema().dump(_dataset_payload())
assert result["sql"] == "SELECT 1"
assert result["perm"] == "[db].[table]"
assert "database" in result
def test_dashboard_external_url_accepts_https() -> None:
"""A valid https external_url is accepted."""
schema = DashboardPostSchema()
result = schema.load(
{
"dashboard_title": "test",
"external_url": "https://example.com/managed",
}
)
assert result["external_url"] == "https://example.com/managed"
@pytest.mark.parametrize(
"url",
[
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
],
)
def test_dashboard_external_url_rejects_non_http(url: str) -> None:
"""external_url rejects non-http(s) schemes."""
schema = DashboardPostSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load({"dashboard_title": "test", "external_url": url})
assert "external_url" in exc_info.value.messages
@pytest.mark.parametrize(
"css",
[
"",
".header { color: red; font-weight: bold; }",
"div { background: url('/static/assets/images/bg.png') no-repeat; }",
"div { background: url(data:image/png;base64,iVBORw0KGgo=); }",
"a { color: #fff; } /* link to https://example.com is fine */",
],
)
def test_dashboard_css_accepts_legitimate_styles(css: str) -> None:
"""Ordinary CSS, including image url() references, is accepted."""
schema = DashboardPostSchema()
result = schema.load({"dashboard_title": "test", "css": css})
assert result["css"] == css
@pytest.mark.parametrize(
"css",
[
"div { width: expression(alert(1)); }",
"div { background: url(javascript:alert(1)); }",
"body { background: url( 'javascript:alert(1)' ); }",
"@import url('https://evil.example.com/x.css');",
"a { content: 'javascript:alert(1)'; }",
"div { behavior: url(vbscript:msgbox(1)); }",
],
)
def test_dashboard_css_rejects_dangerous_constructs(css: str) -> None:
"""Custom CSS with script-ish constructs is rejected on input."""
schema = DashboardPostSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load({"dashboard_title": "test", "css": css})
assert "css" in exc_info.value.messages
def test_dashboard_put_css_rejects_dangerous_constructs() -> None:
"""The PUT schema applies the same CSS hardening."""
schema = DashboardPutSchema()
with pytest.raises(ValidationError) as exc_info:
schema.load({"css": "div { width: expression(alert(1)); }"})
assert "css" in exc_info.value.messages
def test_dashboard_copy_css_rejects_dangerous_constructs() -> None:
"""The Copy schema applies the same CSS hardening."""
schema = DashboardCopySchema()
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"json_metadata": "{}",
"css": "div { width: expression(alert(1)); }",
}
)
assert "css" in exc_info.value.messages
@@ -393,3 +393,34 @@ def test_ssh_tunnel_server_address_rejects_non_hostnames() -> None:
for bad in ("http://evil/", "1.2.3.4/../x", "a b", "file:///etc/passwd"):
with pytest.raises(ValidationError):
schema.load({**base, "server_address": bad})
def test_ssh_tunnel_credentials_load_only() -> None:
"""
Credential fields on DatabaseSSHTunnel are accepted on input (load) but
never serialized in output (dump).
"""
from superset.databases.schemas import DatabaseSSHTunnel
schema = DatabaseSSHTunnel()
payload = {
"server_address": "localhost",
"server_port": 22,
"username": "user",
"password": "secret", # noqa: S106
"private_key": "PRIVATE",
"private_key_password": "keysecret", # noqa: S106
}
# Load accepts the credential fields
loaded = schema.load(payload)
assert loaded["password"] == "secret" # noqa: S105
assert loaded["private_key"] == "PRIVATE"
assert loaded["private_key_password"] == "keysecret" # noqa: S105
# Dump never emits the credential fields
dumped = schema.dump(payload)
assert "password" not in dumped
assert "private_key" not in dumped
assert "private_key_password" not in dumped
assert dumped["server_address"] == "localhost"