mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
178 lines
7.0 KiB
Python
178 lines
7.0 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.
|
|
"""
|
|
Redaction of error details for embedded (guest token) viewers.
|
|
|
|
Errors raised while running a query are relayed verbatim to the client so chart
|
|
authors can fix them. For an embedded viewer that detail is both unusable and
|
|
sensitive: engine errors routinely quote catalog, schema, table and column
|
|
names of the underlying warehouse. Guest responses therefore carry a generic
|
|
message unless the error is one Superset authored itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from typing import Any
|
|
|
|
from flask_babel import lazy_gettext as _
|
|
|
|
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
|
|
|
GENERIC_ERROR_MESSAGE = _("An error occurred while fetching the data.")
|
|
GENERIC_ACCESS_MESSAGE = _("You don't have permission to access this resource.")
|
|
|
|
# Error types Superset raises on its own, describing an access decision, a
|
|
# malformed payload or a client-side condition. Their messages are written by
|
|
# Superset rather than echoed from the database, so they survive redaction.
|
|
SAFE_ERROR_TYPES = frozenset(
|
|
{
|
|
SupersetErrorType.FRONTEND_CSRF_ERROR,
|
|
SupersetErrorType.FRONTEND_NETWORK_ERROR,
|
|
SupersetErrorType.FRONTEND_TIMEOUT_ERROR,
|
|
SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.MISSING_OWNERSHIP_ERROR,
|
|
SupersetErrorType.USER_ACTIVITY_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.DASHBOARD_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.CHART_SECURITY_ACCESS_ERROR,
|
|
SupersetErrorType.OAUTH2_REDIRECT,
|
|
SupersetErrorType.OAUTH2_REDIRECT_ERROR,
|
|
SupersetErrorType.BACKEND_TIMEOUT_ERROR,
|
|
SupersetErrorType.SQLLAB_TIMEOUT_ERROR,
|
|
SupersetErrorType.RESULT_TOO_LARGE_ERROR,
|
|
SupersetErrorType.INVALID_PAYLOAD_FORMAT_ERROR,
|
|
SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR,
|
|
SupersetErrorType.MARSHMALLOW_ERROR,
|
|
}
|
|
)
|
|
|
|
# Keys an allowlisted error may keep. ``issue_codes`` is Superset's own generic
|
|
# guidance; the OAuth2 trio drives the redirect dance in
|
|
# ``OAuth2RedirectMessage.tsx``. Everything else is dropped -- notably the
|
|
# upstream provider response ``OAuth2TokenRefreshError`` stores under
|
|
# ``extra["error"]``, which reaches the client under an allowlisted type.
|
|
SAFE_EXTRA_KEYS = frozenset({"issue_codes", "url", "tab_id", "redirect_uri"})
|
|
|
|
# Statuses reporting an authentication or authorization decision. The message is
|
|
# still replaced, but with one that reads as a denial rather than a data error.
|
|
ACCESS_STATUSES = frozenset({401, 403})
|
|
|
|
|
|
def is_sanitization_required() -> bool:
|
|
"""
|
|
Whether the principal of the current request is an embedded guest viewer.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from superset import security_manager
|
|
|
|
return security_manager.is_guest_user()
|
|
|
|
|
|
def sanitize_error_message(message: str, status: int | None = None) -> str:
|
|
"""
|
|
Replace an error message with a generic one for embedded guest viewers.
|
|
|
|
A message is never kept on the strength of its status alone: an unstructured
|
|
string carries no error type, so there is no way to tell an authorization
|
|
denial from an engine error that happens to be reported as a 404. `status`
|
|
only selects which generic message reads correctly.
|
|
"""
|
|
if not is_sanitization_required():
|
|
return message
|
|
if status in ACCESS_STATUSES:
|
|
return str(GENERIC_ACCESS_MESSAGE)
|
|
return str(GENERIC_ERROR_MESSAGE)
|
|
|
|
|
|
def sanitize_superset_error(error: SupersetError) -> SupersetError:
|
|
"""
|
|
Replace a ``SupersetError`` with a generic one for embedded guest viewers.
|
|
|
|
``extra`` is dropped along with the message: it carries engine names and, for
|
|
some error types, the offending SQL. An allowlisted error keeps its message
|
|
and type, but its ``extra`` is still filtered to `SAFE_EXTRA_KEYS` -- an
|
|
allowlisted type is not a promise that everything hanging off it is safe.
|
|
"""
|
|
if not is_sanitization_required():
|
|
return error
|
|
if error.error_type in SAFE_ERROR_TYPES:
|
|
if not error.extra:
|
|
return error
|
|
extra = {k: v for k, v in error.extra.items() if k in SAFE_EXTRA_KEYS}
|
|
if extra == error.extra:
|
|
return error
|
|
return SupersetError(
|
|
message=error.message,
|
|
error_type=error.error_type,
|
|
level=error.level,
|
|
extra=extra or None,
|
|
)
|
|
return SupersetError(
|
|
message=str(GENERIC_ERROR_MESSAGE),
|
|
error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
|
|
level=error.level,
|
|
)
|
|
|
|
|
|
def sanitize_superset_errors(errors: list[SupersetError]) -> list[SupersetError]:
|
|
"""
|
|
Replace each leaky ``SupersetError`` with a generic one for guest viewers.
|
|
"""
|
|
if not is_sanitization_required():
|
|
return errors
|
|
return [sanitize_superset_error(error) for error in errors]
|
|
|
|
|
|
def sanitize_error_dicts(errors: list[Any]) -> list[Any]:
|
|
"""
|
|
Same as :func:`sanitize_superset_errors`, for already serialized errors.
|
|
|
|
Entries that aren't ``SupersetError`` shaped — a bare string, or a dict with
|
|
only a message — are treated as leaky and replaced wholesale.
|
|
"""
|
|
if not is_sanitization_required():
|
|
return errors
|
|
|
|
sanitized = []
|
|
for error in errors:
|
|
payload: dict[str, Any] = (
|
|
error if isinstance(error, dict) else {"message": str(error)}
|
|
)
|
|
# An unrecognized type is treated as leaky rather than raising.
|
|
try:
|
|
error_type = SupersetErrorType(payload["error_type"])
|
|
level = ErrorLevel(payload.get("level", ErrorLevel.ERROR))
|
|
except (KeyError, ValueError):
|
|
error_type = SupersetErrorType.GENERIC_BACKEND_ERROR
|
|
level = ErrorLevel.ERROR
|
|
sanitized.append(
|
|
dataclasses.asdict(
|
|
sanitize_superset_error(
|
|
SupersetError(
|
|
message=payload.get("message", ""),
|
|
error_type=error_type,
|
|
level=level,
|
|
extra=payload.get("extra"),
|
|
)
|
|
)
|
|
)
|
|
)
|
|
return sanitized
|