mirror of
https://github.com/apache/superset.git
synced 2026-08-12 11:11:01 +00:00
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:
co-authored by
Claude Code
parent
1bfdb19e88
commit
0a1e51f542
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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`"
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user