Skip to content

Commit 7dd70ea

Browse files
rusackasclaude
andauthored
fix: tighten object-level and destination checks across four unrelated endpoints (#44036)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 48795f9 commit 7dd70ea

24 files changed

Lines changed: 1210 additions & 40 deletions

File tree

‎superset/commands/database/exceptions.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ def __init__(self, field_name: str = "sqlalchemy_uri") -> None:
5555
super().__init__(
5656
_(
5757
"This update would change the connection's effective "
58-
"destination (host/port, engine parameters, or SSH tunnel "
59-
"endpoint) while reusing the stored credential. Provide "
60-
"the real password (or SSH tunnel credential) to confirm "
61-
"a connection move."
58+
"destination (host/port, engine parameters, SSH tunnel "
59+
"endpoint, or OAuth2 endpoint URIs) while reusing the stored "
60+
"credential. Provide the real password (or SSH tunnel "
61+
"credential / OAuth2 client secret) to confirm a connection "
62+
"move."
6263
),
6364
field_name=field_name,
6465
)

‎superset/commands/database/update.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from superset.commands.database.sync_permissions import SyncPermissionsCommand
3838
from superset.commands.database.utils import (
3939
engine_params_changed,
40+
oauth2_endpoint_rebind_unsafe,
4041
ssh_tunnel_rebind_unsafe,
4142
uri_identity_changed,
4243
)
@@ -255,8 +256,9 @@ def validate(self) -> None:
255256
def _check_no_unsafe_secret_rebind(self) -> None:
256257
"""
257258
Refuse an update that changes the connection's effective destination
258-
(URI host/port, `extra.engine_params`, or the SSH tunnel endpoint)
259-
while leaving the corresponding stored secret masked.
259+
(URI host/port, `extra.engine_params`, the SSH tunnel endpoint, or the
260+
OAuth2 endpoint URIs in `encrypted_extra`) while leaving the
261+
corresponding stored secret masked.
260262
261263
Without this, an editor could silently redirect the real stored
262264
password/encrypted_extra/SSH tunnel credential to a different
@@ -327,3 +329,17 @@ def _check_no_unsafe_secret_rebind(self) -> None:
327329
raise DatabaseInvalidError(
328330
exceptions=[DatabaseUpdateUnsafeRebindError(field_name="ssh_tunnel")]
329331
)
332+
333+
# The OAuth2 endpoints live inside encrypted_extra, so a change there is
334+
# invisible to the URI/engine-params check above -- yet the stored client
335+
# secret is what the next token exchange posts to the new endpoint.
336+
if "masked_encrypted_extra" in self._properties and (
337+
oauth2_endpoint_rebind_unsafe(
338+
model.encrypted_extra, self._properties["masked_encrypted_extra"]
339+
)
340+
):
341+
raise DatabaseInvalidError(
342+
exceptions=[
343+
DatabaseUpdateUnsafeRebindError(field_name="masked_encrypted_extra")
344+
]
345+
)

‎superset/commands/database/utils.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,53 @@ def ssh_tunnel_rebind_unsafe(
135135
return not has_fresh_credential or stale_private_key_password
136136

137137

138+
OAUTH2_ENDPOINT_FIELDS = (
139+
"authorization_request_uri",
140+
"token_request_uri",
141+
"redirect_uri",
142+
)
143+
144+
145+
def oauth2_endpoint_rebind_unsafe(
146+
existing_encrypted_extra: str | None, submitted_masked_encrypted_extra: str | None
147+
) -> bool:
148+
"""
149+
Whether a submitted ``masked_encrypted_extra`` repoints the OAuth2 client
150+
at different endpoint URIs while reusing the stored client secret (the
151+
``$.oauth2_client_info.secret`` mask is restored verbatim by
152+
``unmask_encrypted_extra`` before the update persists).
153+
154+
The URI/engine-params destination check does not see these fields, yet
155+
the next token exchange posts the real client secret to whatever
156+
``token_request_uri`` now says -- so an endpoint change must come with a
157+
freshly supplied secret, exactly like a host change must come with a
158+
fresh password.
159+
"""
160+
try:
161+
existing = json.loads(existing_encrypted_extra or "{}")
162+
submitted = json.loads(submitted_masked_encrypted_extra or "{}")
163+
except (TypeError, ValueError):
164+
return False # malformed payloads are rejected by schema validation elsewhere
165+
existing_info = (
166+
existing.get("oauth2_client_info") if isinstance(existing, dict) else None
167+
)
168+
submitted_info = (
169+
submitted.get("oauth2_client_info") if isinstance(submitted, dict) else None
170+
)
171+
if not isinstance(existing_info, dict) or not isinstance(submitted_info, dict):
172+
return False
173+
if not existing_info.get("secret"):
174+
return False # nothing stored to carry over
175+
# Only the mask sentinel restores the stored secret; an absent key drops it,
176+
# and a different value is a fresh secret the caller is entitled to attach.
177+
secret_reused = submitted_info.get("secret") == PASSWORD_MASK
178+
endpoint_changed = any(
179+
existing_info.get(field) != submitted_info.get(field)
180+
for field in OAUTH2_ENDPOINT_FIELDS
181+
)
182+
return secret_reused and endpoint_changed
183+
184+
138185
def ping(engine: Engine) -> bool:
139186
try:
140187
time_delta = app.config["TEST_DATABASE_CONNECTION_TIMEOUT"]

‎superset/commands/semantic_layer/update.py‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
)
3636
from superset.commands.semantic_layer.utils import validate_configuration
3737
from superset.commands.utils import current_user_can_modify_object
38+
from superset.constants import PASSWORD_MASK
3839
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
3940
from superset.exceptions import SupersetSecurityException
4041
from superset.semantic_layers.masking import (
@@ -49,6 +50,10 @@
4950

5051
logger = logging.getLogger(__name__)
5152

53+
# Sentinel distinguishing "key absent" from "key present with value None"
54+
# when reading the stored configuration -- dict.get's own default can't.
55+
_MISSING = object()
56+
5257

5358
def _unmask_configuration(
5459
existing_raw_configuration: str | None,
@@ -64,13 +69,61 @@ def _unmask_configuration(
6469
otherwise overwrite the real stored values with the mask string. This
6570
delegates to :func:`superset.semantic_layers.masking.unmask_configuration`,
6671
which restores masked values recursively so nested/union secrets survive
67-
the round-trip too, not just top-level ones."""
72+
the round-trip too, not just top-level ones.
73+
74+
A masked top-level key is only ever restored, though, when every OTHER
75+
submitted top-level key is unchanged from what's stored -- including a
76+
stored key being dropped from the payload -- i.e. this is a pure "reveal
77+
what I was shown masked" round-trip, not an edit that also changes some
78+
other connector field. Without that check, an editor (entitled to edit
79+
this connection, but not to see its real secret -- that's the entire
80+
reason GET/list mask it) could reveal a masked value while simultaneously
81+
changing a destination-relevant field in the same request, poisoning the
82+
stored configuration: the very next legitimate call through this layer
83+
(``POST /<uuid>/schema/runtime`` always uses the stored, now-poisoned
84+
configuration) would send the real secret to wherever that field now
85+
points. Semantic layer connector schemas are pluggable and defined
86+
outside this repo (see ``superset/core/api/core_api_injection.py``), so
87+
unlike the analogous database-connection fix there's no fixed
88+
"destination fields" list to narrow this to -- any other top-level field
89+
changing at all is treated as unsafe to combine with a secret reveal.
90+
"""
6891
try:
6992
existing_configuration: dict[str, Any] = (
7093
json.loads(existing_raw_configuration) if existing_raw_configuration else {}
7194
)
7295
except (TypeError, ValueError):
7396
existing_configuration = {}
97+
98+
masked_keys = {
99+
key
100+
for key, value in new_configuration.items()
101+
if value == PASSWORD_MASK and key in existing_configuration
102+
}
103+
# `.get(key)` alone can't tell "key absent from storage" apart from "key
104+
# present and stored as None" -- both return None -- so a newly
105+
# introduced key with an explicit None value would be misread as
106+
# unchanged and let a masked secret slip through alongside it. A
107+
# sentinel default makes that distinction explicit. Iterating only the
108+
# submitted keys would also miss a REMOVED key: the update replaces the
109+
# stored dictionary wholesale, so dropping an optional field while
110+
# reusing the masked secret changes the effective configuration just as
111+
# surely as editing one.
112+
removed_keys = set(existing_configuration) - set(new_configuration)
113+
if masked_keys and (
114+
removed_keys
115+
or any(
116+
key not in masked_keys
117+
and existing_configuration.get(key, _MISSING) != value
118+
for key, value in new_configuration.items()
119+
)
120+
):
121+
raise SemanticLayerInvalidError(
122+
"This update changes the configuration while reusing a stored "
123+
"secret value (a masked field). Provide the real value for any "
124+
"masked field to confirm a configuration change."
125+
)
126+
74127
masked_reference: dict[str, Any] = mask_configuration(
75128
layer_type, existing_configuration
76129
)

‎superset/config.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,6 +2777,15 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # noq
27772777
# Timeout when fetching access and refresh tokens.
27782778
DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30)
27792779

2780+
# When True, the OAuth2 authorization/token endpoint URIs configured for a
2781+
# database (either via DATABASE_OAUTH2_CLIENTS or, per-connection, via a
2782+
# database's own encrypted_extra.oauth2_client_info) are permitted to target
2783+
# hosts in private/internal IP ranges (RFC-1918, loopback, link-local).
2784+
# Intended for deployments with a legitimately internal identity provider.
2785+
# Leave False (the default) in any deployment where untrusted users can
2786+
# create or edit database connections.
2787+
DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS: bool = False
2788+
27802789
# Enable/disable CSP warning
27812790
CONTENT_SECURITY_POLICY_WARNING = True
27822791

‎superset/db_engine_specs/base.py‎

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,10 @@
3535
TypedDict,
3636
Union,
3737
)
38-
from urllib.parse import urlencode, urljoin
38+
from urllib.parse import urlencode, urljoin, urlparse
3939
from uuid import UUID, uuid4
4040

4141
import pandas as pd
42-
import requests
4342
from apispec import APISpec
4443
from apispec.ext.marshmallow import MarshmallowPlugin
4544
from deprecation import deprecated
@@ -95,7 +94,12 @@
9594
from superset.utils.core import ColumnSpec, GenericDataType, QuerySource
9695
from superset.utils.hashing import hash_from_str
9796
from superset.utils.json import redact_sensitive, reveal_sensitive
98-
from superset.utils.network import is_hostname_valid, is_port_open
97+
from superset.utils.network import (
98+
get_ssrf_safe_requester,
99+
is_hostname_valid,
100+
is_port_open,
101+
is_safe_host,
102+
)
99103
from superset.utils.oauth2 import (
100104
encode_oauth2_state,
101105
generate_code_challenge,
@@ -901,6 +905,47 @@ def get_oauth2_config(cls) -> OAuth2ClientConfig | None:
901905

902906
return config
903907

908+
@staticmethod
909+
def _validate_oauth2_endpoint_host(uri: str) -> None:
910+
"""
911+
Validate an OAuth2 authorization/token endpoint URI before it's used.
912+
913+
``config["authorization_request_uri"]``/``config["token_request_uri"]``
914+
can come from a database's own ``encrypted_extra.oauth2_client_info``
915+
(editable by anyone with ``can_write`` on Database, not just the
916+
deployment operator). The authorization URI is handed to the user's
917+
browser as a redirect target; the token URI is POSTed to directly by
918+
this server, carrying the connection's ``client_secret`` in the
919+
request body. Neither is otherwise validated, so an attacker with
920+
write access to one database's config could point either at an
921+
internal host, exfiltrating the client secret (token URI) or using
922+
Superset as an open redirect into the internal network (authorization
923+
URI) -- and since the connection is typically shared, this is
924+
exercised by every user who goes through that database's OAuth2 flow,
925+
not just the one who configured it.
926+
927+
Operators with a legitimately internal IdP can opt out via
928+
``DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS`` -- but that flag only
929+
widens which *hosts* are acceptable, not which URI *schemes* are;
930+
a non-http(s) scheme is refused unconditionally.
931+
"""
932+
try:
933+
parsed = urlparse(uri)
934+
except ValueError as ex:
935+
# e.g. an unmatched IPv6 bracket -- urlparse raises rather than
936+
# returning an unusable result.
937+
raise OAuth2Error("Invalid OAuth2 endpoint URI") from ex
938+
939+
if parsed.scheme not in ("http", "https"):
940+
raise OAuth2Error("Invalid OAuth2 endpoint URI")
941+
942+
if app.config["DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS"]:
943+
return
944+
945+
if not parsed.hostname or not is_safe_host(parsed.hostname):
946+
logger.warning("OAuth2 endpoint refused: target host is not allowed")
947+
raise OAuth2Error("Invalid OAuth2 endpoint URI")
948+
904949
@classmethod
905950
def get_oauth2_authorization_uri(
906951
cls,
@@ -916,6 +961,7 @@ def get_oauth2_authorization_uri(
916961
(e.g., Google's prompt=consent).
917962
"""
918963
uri = config["authorization_request_uri"]
964+
cls._validate_oauth2_endpoint_host(uri)
919965
params: dict[str, str] = {
920966
"scope": config["scope"],
921967
"response_type": "code",
@@ -947,6 +993,7 @@ def get_oauth2_token(
947993
"""
948994
timeout = app.config["DATABASE_OAUTH2_TIMEOUT"].total_seconds()
949995
uri = config["token_request_uri"]
996+
cls._validate_oauth2_endpoint_host(uri)
950997
req_body: dict[str, str] = {
951998
"code": code,
952999
"client_id": config["id"],
@@ -959,10 +1006,21 @@ def get_oauth2_token(
9591006
if code_verifier:
9601007
req_body["code_verifier"] = code_verifier
9611008

1009+
# `_validate_oauth2_endpoint_host` only checked the hostname; a
1010+
# server at that (safe) host could still respond with a 30x
1011+
# redirecting the actual request to an internal target, or a
1012+
# low-TTL DNS record could resolve differently by the time this
1013+
# connects (DNS rebinding). Don't follow redirects, and re-validate
1014+
# the address actually connected to.
1015+
requester = get_ssrf_safe_requester(
1016+
allow_unsafe_hosts=app.config["DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS"]
1017+
)
9621018
response = (
963-
requests.post(uri, data=req_body, timeout=timeout)
1019+
requester.post(uri, data=req_body, timeout=timeout, allow_redirects=False)
9641020
if config["request_content_type"] == "data"
965-
else requests.post(uri, json=req_body, timeout=timeout)
1021+
else requester.post(
1022+
uri, json=req_body, timeout=timeout, allow_redirects=False
1023+
)
9661024
)
9671025
response.raise_for_status()
9681026
return response.json()
@@ -978,16 +1036,26 @@ def get_oauth2_fresh_token(
9781036
"""
9791037
timeout = app.config["DATABASE_OAUTH2_TIMEOUT"].total_seconds()
9801038
uri = config["token_request_uri"]
1039+
cls._validate_oauth2_endpoint_host(uri)
9811040
req_body = {
9821041
"client_id": config["id"],
9831042
"client_secret": config["secret"],
9841043
"refresh_token": refresh_token,
9851044
"grant_type": "refresh_token",
9861045
}
1046+
# See the matching comment in ``get_oauth2_token``: the hostname
1047+
# check above doesn't protect against a 30x redirect to an internal
1048+
# target or DNS rebinding, so route through the peer-validating
1049+
# requester and refuse to follow redirects.
1050+
requester = get_ssrf_safe_requester(
1051+
allow_unsafe_hosts=app.config["DATABASE_OAUTH2_ALLOW_INTERNAL_HOSTS"]
1052+
)
9871053
response = (
988-
requests.post(uri, data=req_body, timeout=timeout)
1054+
requester.post(uri, data=req_body, timeout=timeout, allow_redirects=False)
9891055
if config["request_content_type"] == "data"
990-
else requests.post(uri, json=req_body, timeout=timeout)
1056+
else requester.post(
1057+
uri, json=req_body, timeout=timeout, allow_redirects=False
1058+
)
9911059
)
9921060
if response.status_code in (400, 401, 403):
9931061
try:

‎superset/db_engine_specs/gsheets.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ def get_oauth2_authorization_uri(
197197
from superset.utils.oauth2 import encode_oauth2_state, generate_code_challenge
198198

199199
uri = config["authorization_request_uri"]
200+
cls._validate_oauth2_endpoint_host(uri)
200201
params: dict[str, str] = {
201202
"scope": config["scope"],
202203
"response_type": "code",

‎superset/db_engine_specs/snowflake.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ def get_oauth2_authorization_uri(
373373
Return URI for initial OAuth2 request.
374374
"""
375375
uri = config["authorization_request_uri"]
376+
cls._validate_oauth2_endpoint_host(uri)
376377
# When calling the Snowflake OAuth authorization endpoint for a custom client,
377378
# specify only the query parameters documented in the URL below.
378379
# Adding unsupported parameters

‎superset/security/api.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from sqlalchemy.orm import selectinload
3838

3939
from superset.commands.dashboard.embedded.exceptions import (
40+
EmbeddedDashboardAccessDeniedError,
4041
EmbeddedDashboardNotFoundError,
4142
)
4243
from superset.commands.exceptions import ForbiddenError
@@ -223,7 +224,9 @@ def guest_token(self) -> Response:
223224
"""
224225
try:
225226
body = guest_token_create_schema.load(request.json)
226-
self.appbuilder.sm.validate_guest_token_resources(body["resources"])
227+
self.appbuilder.sm.validate_guest_token_resources(
228+
body["resources"], datasets=body.get("datasets")
229+
)
227230
guest_token_validator_hook = current_app.config.get(
228231
"GUEST_TOKEN_VALIDATOR_HOOK"
229232
)
@@ -265,6 +268,14 @@ def guest_token(self) -> Response:
265268
return self.response(200, token=token)
266269
except EmbeddedDashboardNotFoundError as error:
267270
return self.response_400(message=error.message)
271+
except EmbeddedDashboardAccessDeniedError as error:
272+
# The minting principal is not entitled to the dashboard being
273+
# scoped (see validate_guest_token_resources): an authorization
274+
# denial, not a server fault, so answer 403 rather than letting
275+
# @safe turn it into a logged 500.
276+
# FAB 5.x: response_403() takes no message argument (unlike
277+
# response_400), so build the 403 explicitly.
278+
return self.response(403, message=error.message)
268279
except ValidationError as error:
269280
return self.response_400(message=error.messages)
270281

0 commit comments

Comments
 (0)