This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch 
fix/oauth2-guest-token-semantic-layer-boundaries
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4ec3612e2fe8de352a526c9bbe3a79963aff8770
Author: Evan Rusackas <[email protected]>
AuthorDate: Tue Sep 8 17:32:40 2026 -0700

    fix: tighten object-level and destination checks across four unrelated 
endpoints
    
    Four independent fixes, bundled together for review efficiency (each
    touches a different subsystem, no shared root cause):
    
    - superset/db_engine_specs/base.py, gsheets.py, snowflake.py, config.py:
      a database's own encrypted_extra.oauth2_client_info sets
      authorization_request_uri/token_request_uri with no host validation.
      The token endpoint is POSTed to directly by the server, carrying the
      connection's client_secret in the body. Validate both endpoint hosts
      (is_safe_host, matching the existing pattern in
      db_engine_specs/impala.py's cancel_query and
      reports/notifications/webhook.py) before use, with an opt-out config
      flag for legitimately internal identity providers.
    
    - superset/security/manager.py: validate_guest_token_resources only
      checked that a dashboard exists and is embedded, never that the
      principal minting the token has access to it. A narrower
      can_grant_guest_token grant (a realistic non-Admin "embedding backend
      service" role) could mint a valid guest token scoped to any embedded
      dashboard in the instance.
    
    - superset/commands/semantic_layer/update.py: masked configuration
      fields were restored from the stored value unconditionally, without
      checking whether other submitted fields also changed. An editor could
      reveal a masked secret while changing an unrelated field in the same
      request, persisting the secret against changed configuration.
    
    - superset/views/utils.py: get_dashboard_extra_filters resolved a
      dashboard by id with only a chart-membership check, no access check.
      Chart reuse across dashboards means chart access doesn't imply
      dashboard access.
    
    Regression tests added for all four; each verified to fail without its
    corresponding fix and pass with it.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
 superset/commands/semantic_layer/update.py         |  32 ++++++
 superset/config.py                                 |   9 ++
 superset/db_engine_specs/base.py                   |  39 ++++++-
 superset/db_engine_specs/gsheets.py                |   1 +
 superset/db_engine_specs/snowflake.py              |   1 +
 superset/security/manager.py                       |  17 ++-
 superset/views/utils.py                            |  12 ++
 .../commands/semantic_layer/update_test.py         |  43 +++++++
 tests/unit_tests/db_engine_specs/test_base.py      | 126 ++++++++++++++++++++-
 .../unit_tests/db_engine_specs/test_databricks.py  |   4 +
 .../db_engine_specs/test_databricks_multi_cloud.py |   4 +
 tests/unit_tests/db_engine_specs/test_snowflake.py |   1 +
 tests/unit_tests/db_engine_specs/test_trino.py     |   1 +
 tests/unit_tests/security/manager_test.py          |  78 ++++++++++++-
 tests/unit_tests/views/test_utils.py               |  69 ++++++++++-
 15 files changed, 430 insertions(+), 7 deletions(-)

diff --git a/superset/commands/semantic_layer/update.py 
b/superset/commands/semantic_layer/update.py
index f01072d41ce..5a85ef175c5 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -63,6 +63,23 @@ def _unmask_configuration(
     write-only, since a client only ever sends the sentinel back for a value
     it previously received masked (including a value masked by the
     fail-closed fallback).
+
+    A masked field is only ever restored, though, when every OTHER
+    submitted key is unchanged from what's stored -- i.e. this is a pure
+    "reveal what I was shown masked" round-trip, not an edit that also
+    changes some other connector field. Without that check, an editor
+    (entitled to edit this connection, but not to see its real secret --
+    that's the entire reason GET/list mask it) could reveal a masked value
+    while simultaneously changing a destination-relevant field in the same
+    request, poisoning the stored configuration: the very next legitimate
+    call through this layer (``POST /<uuid>/schema/runtime`` always uses
+    the stored, now-poisoned configuration) would send the real secret to
+    wherever that field now points. Semantic layer connector schemas are
+    pluggable and defined outside this repo (see
+    ``superset/core/api/core_api_injection.py``), so unlike the analogous
+    database-connection fix there's no fixed "destination fields" list to
+    narrow this to -- any other field changing at all is treated as unsafe
+    to combine with a secret reveal.
     """
     try:
         existing_configuration = (
@@ -71,6 +88,21 @@ def _unmask_configuration(
     except (TypeError, ValueError):
         existing_configuration = {}
 
+    masked_keys = {
+        key
+        for key, value in new_configuration.items()
+        if value == PASSWORD_MASK and key in existing_configuration
+    }
+    if masked_keys and any(
+        key not in masked_keys and existing_configuration.get(key) != value
+        for key, value in new_configuration.items()
+    ):
+        raise SemanticLayerInvalidError(
+            "This update changes the configuration while reusing a stored "
+            "secret value (a masked field). Provide the real value for any "
+            "masked field to confirm a configuration change."
+        )
+
     return {
         key: (
             existing_configuration[key]
diff --git a/superset/config.py b/superset/config.py
index eb20815eb56..b786f8c7023 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2732,6 +2732,15 @@ DATABASE_OAUTH2_JWT_ALGORITHM = "HS256"
 # Timeout when fetching access and refresh tokens.
 DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30)
 
+# When True, the OAuth2 authorization/token endpoint URIs configured for a
+# database (either via DATABASE_OAUTH2_CLIENTS or, per-connection, via a
+# database's own encrypted_extra.oauth2_client_info) are permitted to target
+# hosts in private/internal IP ranges (RFC-1918, loopback, link-local).
+# Intended for deployments with a legitimately internal identity provider.
+# Leave False (the default) in any deployment where untrusted users can
+# create or edit database connections.
+DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS: bool = False
+
 # Enable/disable CSP warning
 CONTENT_SECURITY_POLICY_WARNING = True
 
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index a287b856281..864319cd949 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -35,7 +35,7 @@ from typing import (
     TypedDict,
     Union,
 )
-from urllib.parse import urlencode, urljoin
+from urllib.parse import urlencode, urljoin, urlparse
 from uuid import UUID, uuid4
 
 import pandas as pd
@@ -95,7 +95,7 @@ from superset.utils import core as utils, json
 from superset.utils.core import ColumnSpec, GenericDataType, QuerySource
 from superset.utils.hashing import hash_from_str
 from superset.utils.json import redact_sensitive, reveal_sensitive
-from superset.utils.network import is_hostname_valid, is_port_open
+from superset.utils.network import is_hostname_valid, is_port_open, 
is_safe_host
 from superset.utils.oauth2 import (
     encode_oauth2_state,
     generate_code_challenge,
@@ -901,6 +901,38 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
 
         return config
 
+    @staticmethod
+    def _validate_oauth2_endpoint_host(uri: str) -> None:
+        """
+        Validate an OAuth2 authorization/token endpoint URI before it's used.
+
+        ``config["authorization_request_uri"]``/``config["token_request_uri"]``
+        can come from a database's own ``encrypted_extra.oauth2_client_info``
+        (editable by anyone with ``can_write`` on Database, not just the
+        deployment operator). The authorization URI is handed to the user's
+        browser as a redirect target; the token URI is POSTed to directly by
+        this server, carrying the connection's ``client_secret`` in the
+        request body. Neither is otherwise validated, so an attacker with
+        write access to one database's config could point either at an
+        internal host, exfiltrating the client secret (token URI) or using
+        Superset as an open redirect into the internal network (authorization
+        URI) -- and since the connection is typically shared, this is
+        exercised by every user who goes through that database's OAuth2 flow,
+        not just the one who configured it.
+
+        Operators with a legitimately internal IdP can opt out via
+        ``DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS``.
+        """
+        if app.config["DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS"]:
+            return
+
+        parsed = urlparse(uri)
+        if parsed.scheme not in ("http", "https"):
+            raise OAuth2Error("Invalid OAuth2 endpoint URI")
+        if not parsed.hostname or not is_safe_host(parsed.hostname):
+            logger.warning("OAuth2 endpoint refused: target host is not 
allowed")
+            raise OAuth2Error("Invalid OAuth2 endpoint URI")
+
     @classmethod
     def get_oauth2_authorization_uri(
         cls,
@@ -916,6 +948,7 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
         (e.g., Google's prompt=consent).
         """
         uri = config["authorization_request_uri"]
+        cls._validate_oauth2_endpoint_host(uri)
         params: dict[str, str] = {
             "scope": config["scope"],
             "response_type": "code",
@@ -947,6 +980,7 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
         """
         timeout = app.config["DATABASE_OAUTH2_TIMEOUT"].total_seconds()
         uri = config["token_request_uri"]
+        cls._validate_oauth2_endpoint_host(uri)
         req_body: dict[str, str] = {
             "code": code,
             "client_id": config["id"],
@@ -978,6 +1012,7 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
         """
         timeout = app.config["DATABASE_OAUTH2_TIMEOUT"].total_seconds()
         uri = config["token_request_uri"]
+        cls._validate_oauth2_endpoint_host(uri)
         req_body = {
             "client_id": config["id"],
             "client_secret": config["secret"],
diff --git a/superset/db_engine_specs/gsheets.py 
b/superset/db_engine_specs/gsheets.py
index 7fd1ac1156b..3d2e317d8c4 100644
--- a/superset/db_engine_specs/gsheets.py
+++ b/superset/db_engine_specs/gsheets.py
@@ -197,6 +197,7 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
         from superset.utils.oauth2 import encode_oauth2_state, 
generate_code_challenge
 
         uri = config["authorization_request_uri"]
+        cls._validate_oauth2_endpoint_host(uri)
         params: dict[str, str] = {
             "scope": config["scope"],
             "response_type": "code",
diff --git a/superset/db_engine_specs/snowflake.py 
b/superset/db_engine_specs/snowflake.py
index 63ae8886228..c1bcd86dca3 100644
--- a/superset/db_engine_specs/snowflake.py
+++ b/superset/db_engine_specs/snowflake.py
@@ -373,6 +373,7 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
         Return URI for initial OAuth2 request.
         """
         uri = config["authorization_request_uri"]
+        cls._validate_oauth2_endpoint_host(uri)
         # When calling the Snowflake OAuth authorization endpoint for a custom 
client,
         # specify only the query parameters documented in the URL below.
         # Adding unsupported parameters
diff --git a/superset/security/manager.py b/superset/security/manager.py
index ac8910b043f..303d3cbaf55 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -5332,10 +5332,10 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
             audience = audience()
         return audience
 
-    @staticmethod
-    def validate_guest_token_resources(resources: GuestTokenResources) -> None:
+    def validate_guest_token_resources(self, resources: GuestTokenResources) 
-> None:
         # pylint: disable=import-outside-toplevel
         from superset.commands.dashboard.embedded.exceptions import (
+            EmbeddedDashboardAccessDeniedError,
             EmbeddedDashboardNotFoundError,
         )
         from superset.daos.dashboard import EmbeddedDashboardDAO
@@ -5349,11 +5349,24 @@ class SupersetSecurityManager(  # pylint: 
disable=too-many-public-methods
                     embedded = 
EmbeddedDashboardDAO.find_by_id(str(resource["id"]))
                     if not embedded:
                         raise EmbeddedDashboardNotFoundError()
+                    dashboard = embedded.dashboard
                 elif not dashboard.embedded:
                     # A raw dashboard id must still reference an embedded 
dashboard;
                     # otherwise a guest token could be scoped to a 
non-embedded one.
                     raise EmbeddedDashboardNotFoundError()
 
+                # The caller minting the token must themselves be entitled to
+                # the dashboard being scoped. `grant_guest_token` is a
+                # coarse, instance-wide permission -- without this check, an
+                # operator who narrows it to a non-Admin role (a realistic
+                # "embedding backend service" grant) would let that
+                # principal mint a fully valid guest token for *any*
+                # embedded dashboard, not just ones they have access to.
+                try:
+                    self.raise_for_access(dashboard=dashboard)
+                except SupersetSecurityException as ex:
+                    raise EmbeddedDashboardAccessDeniedError() from ex
+
     def create_guest_access_token(
         self,
         user: GuestTokenUser,
diff --git a/superset/views/utils.py b/superset/views/utils.py
index af982778fc7..63bb7037586 100644
--- a/superset/views/utils.py
+++ b/superset/views/utils.py
@@ -44,6 +44,7 @@ from superset.common.db_query_status import QueryStatus
 from superset.exceptions import (
     SerializationError,
     SupersetException,
+    SupersetSecurityException,
 )
 from superset.extensions import security_manager
 from superset.legacy import update_time_range
@@ -504,6 +505,17 @@ def get_dashboard_extra_filters(
     ):
         return []
 
+    # Does the caller actually have access to this dashboard? A chart can
+    # legitimately be reused across multiple dashboards, so passing chart
+    # membership above isn't an entitlement check -- without this, a
+    # principal who owns/can access some chart also embedded on a
+    # dashboard they have no access to could pull that dashboard's default
+    # filter configuration into their own request.
+    try:
+        security_manager.raise_for_access(dashboard=dashboard)
+    except SupersetSecurityException:
+        return []
+
     with contextlib.suppress(json.JSONDecodeError):
         json_metadata = json.loads(dashboard.json_metadata)
         native_filters = [
diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py 
b/tests/unit_tests/commands/semantic_layer/update_test.py
index 9ae58afccf3..800b67e6274 100644
--- a/tests/unit_tests/commands/semantic_layer/update_test.py
+++ b/tests/unit_tests/commands/semantic_layer/update_test.py
@@ -528,6 +528,49 @@ def test_unmask_configuration_missing_existing_key() -> 
None:
     assert result == {"account": "test", "password": PASSWORD_MASK}
 
 
+def test_unmask_configuration_rejects_secret_reveal_with_changed_field() -> 
None:
+    """
+    A masked field must not be revealed in the same update that also
+    changes some other configuration field. An editor is entitled to edit
+    this connection, but not to see its real secret (that's the entire
+    reason the read path masks it) -- revealing it while also changing a
+    potentially destination-relevant field would poison the stored
+    configuration with the real secret attached to attacker-controlled
+    config, silently leaking it on the next legitimate use of this layer.
+    """
+    with pytest.raises(SemanticLayerInvalidError):
+        _unmask_configuration(
+            '{"account": "prod-account", "password": "hunter2"}',
+            {"account": "attacker-account", "password": PASSWORD_MASK},
+        )
+
+
+def test_unmask_configuration_allows_fresh_secret_with_changed_field() -> None:
+    """
+    A deliberate configuration change is still possible when a genuinely
+    fresh (non-masked) secret is supplied alongside it.
+    """
+    result = _unmask_configuration(
+        '{"account": "prod-account", "password": "hunter2"}',
+        {"account": "new-account", "password": "fresh-secret"},
+    )
+
+    assert result == {"account": "new-account", "password": "fresh-secret"}
+
+
+def test_unmask_configuration_allows_unrelated_field_addition_with_no_mask() 
-> None:
+    """
+    Adding/changing fields with no masked value present at all is
+    unaffected -- there's no secret being reused, so nothing to protect.
+    """
+    result = _unmask_configuration(
+        '{"account": "prod-account"}',
+        {"account": "new-account", "extra_option": "value"},
+    )
+
+    assert result == {"account": "new-account", "extra_option": "value"}
+
+
 def test_update_semantic_layer_preserves_masked_secret_end_to_end(
     mocker: MockerFixture,
 ) -> None:
diff --git a/tests/unit_tests/db_engine_specs/test_base.py 
b/tests/unit_tests/db_engine_specs/test_base.py
index e85ebe9be20..fca638d7d03 100644
--- a/tests/unit_tests/db_engine_specs/test_base.py
+++ b/tests/unit_tests/db_engine_specs/test_base.py
@@ -39,7 +39,7 @@ from superset.db_engine_specs.base import (
     convert_inspector_columns,
 )
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
-from superset.exceptions import OAuth2RedirectError
+from superset.exceptions import OAuth2Error, OAuth2RedirectError
 from superset.sql.parse import Table
 from superset.superset_typing import (
     OAuth2ClientConfig,
@@ -953,6 +953,18 @@ def test_extract_errors_no_match_falls_back(mocker: 
MockerFixture) -> None:
     assert result == [expected]
 
 
[email protected](autouse=True)
+def _mock_safe_oauth2_host(mocker: MockerFixture) -> None:
+    """
+    OAuth2 endpoint URIs are now validated via ``is_safe_host`` (real DNS
+    resolution) before use. The test fixtures below use non-resolving
+    example hostnames, so mock it the same way test_impala.py mocks it for
+    its own SSRF check; SSRF-rejection behavior itself is covered by
+    dedicated tests further down that override this per-test.
+    """
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=True)
+
+
 def test_get_oauth2_authorization_uri_standard_params(mocker: MockerFixture) 
-> None:
     """
     Test that BaseEngineSpec.get_oauth2_authorization_uri uses standard OAuth 
2.0
@@ -1315,6 +1327,117 @@ def 
test_get_oauth2_fresh_token_raises_on_server_error(mocker: MockerFixture) ->
         BaseEngineSpec.get_oauth2_fresh_token(config, "refresh-token")
 
 
+def _oauth2_config_targeting(uri: str) -> OAuth2ClientConfig:
+    return {
+        "id": "client-id",
+        "secret": "client-secret",
+        "scope": "read write",
+        "redirect_uri": "http://localhost:8088/api/v1/database/oauth2/";,
+        "authorization_request_uri": uri,
+        "token_request_uri": uri,
+        "request_content_type": "json",
+    }
+
+
+def test_get_oauth2_token_rejects_unsafe_host(mocker: MockerFixture) -> None:
+    """
+    ``token_request_uri`` can come from a database's own
+    ``encrypted_extra.oauth2_client_info`` (editable by anyone with
+    ``can_write`` on Database), and is POSTed to directly by this server
+    carrying the connection's client_secret. An internal/private target
+    must be refused rather than silently reaching it.
+    """
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=False)
+    mock_requests = mocker.patch("superset.db_engine_specs.base.requests")
+
+    config = 
_oauth2_config_targeting("http://169.254.169.254/latest/meta-data/";)
+
+    with pytest.raises(OAuth2Error):
+        BaseEngineSpec.get_oauth2_token(config, "code")
+
+    mock_requests.post.assert_not_called()
+
+
+def test_get_oauth2_fresh_token_rejects_unsafe_host(mocker: MockerFixture) -> 
None:
+    """
+    Same protection as ``get_oauth2_token``, for the refresh-token exchange.
+    """
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=False)
+    mock_requests = mocker.patch("superset.db_engine_specs.base.requests")
+
+    config = _oauth2_config_targeting("http://10.0.0.5/token";)
+
+    with pytest.raises(OAuth2Error):
+        BaseEngineSpec.get_oauth2_fresh_token(config, "refresh-token")
+
+    mock_requests.post.assert_not_called()
+
+
+def test_get_oauth2_authorization_uri_rejects_unsafe_host(
+    mocker: MockerFixture,
+) -> None:
+    """
+    ``authorization_request_uri`` is handed to the user's browser as a
+    redirect target; an internal host would turn Superset into an open
+    redirect into the internal network.
+    """
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=False)
+
+    config = _oauth2_config_targeting("http://192.168.1.1/authorize";)
+    state: OAuth2State = {
+        "database_id": 1,
+        "user_id": 1,
+        "default_redirect_uri": "http://localhost:8088/api/v1/oauth2/";,
+        "tab_id": "1234",
+    }
+
+    with pytest.raises(OAuth2Error):
+        BaseEngineSpec.get_oauth2_authorization_uri(config, state)
+
+
+def test_oauth2_endpoint_rejects_non_http_scheme(mocker: MockerFixture) -> 
None:
+    """
+    A non-http(s) scheme is refused outright, before any host resolution.
+    """
+    is_safe_host = mocker.patch("superset.db_engine_specs.base.is_safe_host")
+    mock_requests = mocker.patch("superset.db_engine_specs.base.requests")
+
+    config = _oauth2_config_targeting("file:///etc/passwd")
+
+    with pytest.raises(OAuth2Error):
+        BaseEngineSpec.get_oauth2_token(config, "code")
+
+    is_safe_host.assert_not_called()
+    mock_requests.post.assert_not_called()
+
+
+def test_oauth2_endpoint_allows_internal_host_when_configured(
+    mocker: MockerFixture,
+) -> None:
+    """
+    Operators with a legitimately internal IdP can opt out via
+    DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS -- the host is not even checked
+    once that's set.
+    """
+    mocker.patch.dict(
+        "superset.db_engine_specs.base.app.config",
+        {"DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS": True},
+    )
+    is_safe_host = mocker.patch("superset.db_engine_specs.base.is_safe_host")
+    mock_post = mocker.patch("superset.db_engine_specs.base.requests.post")
+    mock_post.return_value.json.return_value = {
+        "access_token": "access-token",
+        "expires_in": 3600,
+    }
+
+    config = _oauth2_config_targeting("http://10.0.0.5/token";)
+
+    BaseEngineSpec.get_oauth2_token(config, "code")
+
+    is_safe_host.assert_not_called()
+    mock_post.assert_called_once()
+
+
 def test_start_oauth2_dance_uses_config_redirect_uri(mocker: MockerFixture) -> 
None:
     """
     Test that start_oauth2_dance uses DATABASE_OAUTH2_REDIRECT_URI config if 
set.
@@ -1327,6 +1450,7 @@ def 
test_start_oauth2_dance_uses_config_redirect_uri(mocker: MockerFixture) -> N
             "DATABASE_OAUTH2_REDIRECT_URI": custom_redirect_uri,
             "SECRET_KEY": "test-secret-key",
             "DATABASE_OAUTH2_JWT_ALGORITHM": "HS256",
+            "DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS": True,
         },
     )
     mocker.patch("superset.daos.key_value.KeyValueDAO")
diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py 
b/tests/unit_tests/db_engine_specs/test_databricks.py
index a963a764d57..fa134303e5f 100644
--- a/tests/unit_tests/db_engine_specs/test_databricks.py
+++ b/tests/unit_tests/db_engine_specs/test_databricks.py
@@ -989,6 +989,10 @@ def 
test_get_oauth2_authorization_uri_derives_from_workspace_host(
     database = mocker.MagicMock()
     database.url_object.host = host
     mocker.patch("superset.db.session.get", return_value=database)
+    # is_safe_host does live DNS resolution; whether these fixture hosts
+    # happen to resolve depends on real-world DNS state outside test
+    # control, so pin it rather than relying on that.
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=True)
 
     url = spec.get_oauth2_authorization_uri(
         _unresolved_oauth2_config(), _oauth2_state()
diff --git a/tests/unit_tests/db_engine_specs/test_databricks_multi_cloud.py 
b/tests/unit_tests/db_engine_specs/test_databricks_multi_cloud.py
index 65f8218e3a1..948d97655b7 100644
--- a/tests/unit_tests/db_engine_specs/test_databricks_multi_cloud.py
+++ b/tests/unit_tests/db_engine_specs/test_databricks_multi_cloud.py
@@ -91,6 +91,10 @@ def test_get_oauth2_authorization_uri_uses_workspace_host(
         "superset.db.session.get",
         return_value=_mock_database(mocker, host),
     )
+    # is_safe_host does live DNS resolution; whether these fixture hosts
+    # happen to resolve depends on real-world DNS state outside test
+    # control, so pin it rather than relying on that.
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=True)
 
     state: OAuth2State = {
         "database_id": 1,
diff --git a/tests/unit_tests/db_engine_specs/test_snowflake.py 
b/tests/unit_tests/db_engine_specs/test_snowflake.py
index c7f14e9e337..e176113753c 100644
--- a/tests/unit_tests/db_engine_specs/test_snowflake.py
+++ b/tests/unit_tests/db_engine_specs/test_snowflake.py
@@ -542,6 +542,7 @@ def test_get_oauth2_token(
     """
     from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
 
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=True)
     requests: mock.MagicMock = 
mocker.patch("superset.db_engine_specs.base.requests")
     requests.post().json.return_value = {
         "access_token": "access-token",
diff --git a/tests/unit_tests/db_engine_specs/test_trino.py 
b/tests/unit_tests/db_engine_specs/test_trino.py
index 0d744d16ae1..5e22f9f626c 100644
--- a/tests/unit_tests/db_engine_specs/test_trino.py
+++ b/tests/unit_tests/db_engine_specs/test_trino.py
@@ -1109,6 +1109,7 @@ def test_get_oauth2_token(
     """
     from superset.db_engine_specs.trino import TrinoEngineSpec
 
+    mocker.patch("superset.db_engine_specs.base.is_safe_host", 
return_value=True)
     requests = mocker.patch("superset.db_engine_specs.base.requests")
     requests.post().json.return_value = {
         "access_token": "access-token",
diff --git a/tests/unit_tests/security/manager_test.py 
b/tests/unit_tests/security/manager_test.py
index ebf73db4927..08b3ab7c880 100644
--- a/tests/unit_tests/security/manager_test.py
+++ b/tests/unit_tests/security/manager_test.py
@@ -3985,18 +3985,94 @@ def 
test_validate_guest_token_resources_rejects_non_embedded_int_id(
 def test_validate_guest_token_resources_accepts_embedded_int_id(
     app_context: None, mocker: MockerFixture
 ) -> None:
-    """A raw int id for an embedded dashboard is accepted."""
+    """A raw int id for an embedded dashboard is accepted when the caller
+    minting the token is entitled to it."""
     from superset.security.guest_token import GuestTokenResourceType
 
     sm = SupersetSecurityManager(appbuilder)
     embedded_dash = MagicMock()
     embedded_dash.embedded = [MagicMock()]  # embedded
     mocker.patch("superset.models.dashboard.Dashboard.get", 
return_value=embedded_dash)
+    raise_for_access = mocker.patch.object(sm, "raise_for_access")
 
     sm.validate_guest_token_resources(
         [{"type": GuestTokenResourceType.DASHBOARD, "id": 5}]
     )
 
+    raise_for_access.assert_called_once_with(dashboard=embedded_dash)
+
+
+def test_validate_guest_token_resources_rejects_unauthorized_dashboard(
+    app_context: None, mocker: MockerFixture
+) -> None:
+    """
+    Minting a guest token for a dashboard the calling principal is not
+    themselves entitled to must be refused. Without this, a non-Admin role
+    granted only the coarse `can_grant_guest_token` permission (a realistic
+    "embedding backend service" grant, since SECURITY.md treats an
+    operator-narrowed permission as shifting the boundary, not redefining
+    the model) could mint a valid guest token scoped to *any* embedded
+    dashboard in the instance, not just ones it can see.
+    """
+    from superset.commands.dashboard.embedded.exceptions import (
+        EmbeddedDashboardAccessDeniedError,
+    )
+    from superset.exceptions import SupersetSecurityException
+    from superset.security.guest_token import GuestTokenResourceType
+
+    sm = SupersetSecurityManager(appbuilder)
+    embedded_dash = MagicMock()
+    embedded_dash.embedded = [MagicMock()]  # embedded
+    mocker.patch("superset.models.dashboard.Dashboard.get", 
return_value=embedded_dash)
+    mocker.patch.object(
+        sm,
+        "raise_for_access",
+        side_effect=SupersetSecurityException(mocker.MagicMock()),
+    )
+
+    with pytest.raises(EmbeddedDashboardAccessDeniedError):
+        sm.validate_guest_token_resources(
+            [{"type": GuestTokenResourceType.DASHBOARD, "id": 5}]
+        )
+
+
+def 
test_validate_guest_token_resources_checks_access_via_embedded_dao_fallback(
+    app_context: None, mocker: MockerFixture
+) -> None:
+    """
+    The same access check applies on the EmbeddedDashboardDAO lookup path
+    (a resource id that isn't a plain dashboard id -- e.g. the embedded
+    config's own uuid), not just the `Dashboard.get` path.
+    """
+    from superset.exceptions import SupersetSecurityException
+    from superset.security.guest_token import GuestTokenResourceType
+
+    sm = SupersetSecurityManager(appbuilder)
+    mocker.patch("superset.models.dashboard.Dashboard.get", return_value=None)
+    target_dashboard = MagicMock()
+    embedded = MagicMock()
+    embedded.dashboard = target_dashboard
+    mocker.patch(
+        "superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
+        return_value=embedded,
+    )
+    raise_for_access = mocker.patch.object(
+        sm,
+        "raise_for_access",
+        side_effect=SupersetSecurityException(mocker.MagicMock()),
+    )
+
+    from superset.commands.dashboard.embedded.exceptions import (
+        EmbeddedDashboardAccessDeniedError,
+    )
+
+    with pytest.raises(EmbeddedDashboardAccessDeniedError):
+        sm.validate_guest_token_resources(
+            [{"type": GuestTokenResourceType.DASHBOARD, "id": "some-uuid"}]
+        )
+
+    raise_for_access.assert_called_once_with(dashboard=target_dashboard)
+
 
 def test_is_editor_query_owner(mocker: MockerFixture, app_context: None) -> 
None:
     """
diff --git a/tests/unit_tests/views/test_utils.py 
b/tests/unit_tests/views/test_utils.py
index 805b760c66a..b1f5d65ebc8 100644
--- a/tests/unit_tests/views/test_utils.py
+++ b/tests/unit_tests/views/test_utils.py
@@ -110,7 +110,10 @@ def 
test_get_dashboard_extra_filters_includes_native_filter_defaults(
     db.session.add_all([chart, dashboard])
     db.session.flush()
 
-    with 
patch("superset.charts.data.dashboard_filter_context._check_dashboard_access"):
+    with (
+        
patch("superset.charts.data.dashboard_filter_context._check_dashboard_access"),
+        patch("superset.views.utils.security_manager.raise_for_access"),
+    ):
         extra_filters = get_dashboard_extra_filters(chart.id, dashboard.id)
 
     assert extra_filters == [{"col": "region", "op": "IN", "val": ["APAC"]}]
@@ -123,6 +126,7 @@ def 
test_get_dashboard_extra_filters_includes_native_filter_defaults(
 
     with (
         
patch("superset.charts.data.dashboard_filter_context._check_dashboard_access"),
+        patch("superset.views.utils.security_manager.raise_for_access"),
         patch(
             "superset.views.utils.build_extra_filters",
             return_value=[legacy_filter],
@@ -134,3 +138,66 @@ def 
test_get_dashboard_extra_filters_includes_native_filter_defaults(
         legacy_filter,
         {"col": "region", "op": "IN", "val": ["APAC"]},
     ]
+
+
+def test_get_dashboard_extra_filters_denies_unauthorized_dashboard(
+    session: Session,
+) -> None:
+    """
+    A chart can legitimately be reused across multiple dashboards, so
+    chart-membership on a dashboard is not an entitlement check for that
+    dashboard. A caller who can access the chart but not the dashboard it's
+    also placed on must not have that dashboard's filter configuration
+    pulled into their request.
+    """
+    Dashboard.metadata.create_all(session.get_bind())
+
+    dataset = SqlaTable(
+        table_name="unauthorized_dash_table",
+        database=Database(
+            database_name="unauthorized_dash_db", sqlalchemy_uri="sqlite://"
+        ),
+    )
+    db.session.add(dataset)
+    db.session.flush()
+
+    chart = Slice(
+        slice_name="shared_chart",
+        datasource_id=dataset.id,
+        datasource_type="table",
+    )
+    dashboard = Dashboard(
+        dashboard_title="dashboard_caller_cant_access",
+        slices=[chart],
+        published=True,
+        json_metadata=json.dumps(
+            {
+                "default_filters": json.dumps(
+                    {"legacy-filter": {"country": ["Brazil"]}}
+                ),
+                "filter_scopes": {},
+            }
+        ),
+        position_json="{}",
+    )
+    db.session.add_all([chart, dashboard])
+    db.session.flush()
+
+    from unittest.mock import MagicMock
+
+    from superset.exceptions import SupersetSecurityException
+
+    with (
+        
patch("superset.charts.data.dashboard_filter_context._check_dashboard_access"),
+        patch(
+            "superset.views.utils.security_manager.raise_for_access",
+            side_effect=SupersetSecurityException(MagicMock()),
+        ),
+        patch(
+            "superset.views.utils.build_extra_filters",
+            return_value=[{"col": "country", "op": "in", "val": ["Brazil"]}],
+        ),
+    ):
+        extra_filters = get_dashboard_extra_filters(chart.id, dashboard.id)
+
+    assert extra_filters == []

Reply via email to