3535 TypedDict ,
3636 Union ,
3737)
38- from urllib .parse import urlencode , urljoin
38+ from urllib .parse import urlencode , urljoin , urlparse
3939from uuid import UUID , uuid4
4040
4141import pandas as pd
42- import requests
4342from apispec import APISpec
4443from apispec .ext .marshmallow import MarshmallowPlugin
4544from deprecation import deprecated
9594from superset .utils .core import ColumnSpec , GenericDataType , QuerySource
9695from superset .utils .hashing import hash_from_str
9796from 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+ )
99103from 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 :
0 commit comments