Skip to content

Commit

Permalink
WIP: feat: redirect uri wildcards
Browse files Browse the repository at this point in the history
  • Loading branch information
dopry committed Sep 28, 2024
1 parent 631291d commit c2b9ea5
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 25 deletions.
38 changes: 38 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,44 @@ assigned ports.
Note that you may override ``Application.get_allowed_schemes()`` to set this on
a per-application basis.

ALLOW_REDIRECT_URI_WILDCARDS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Default: ``False``

SECURITY WARNING: Enabling this setting can introduce security vulnerabilities. Only enable
this setting if you understand the risks. https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
states "The redirection endpoint URI MUST be an absolute URI as defined by [RFC3986] Section 4.3." The
intent of the URI restrictions is to prevent open redirects and phishing attacks. If you do enable this
ensure that the wildcards restrict URIs to resources under your control. You are strongly encouragd not
to use this feature in production.

When set to ``True``, the server will allow wildcard characters in the domains
and paths for redirect_uris and post_logout_redirect_uris.

``*`` is the only wildcard character allowed.

``*`` can only be used as a prefix to a domain, must be the first character in
the domain, and cannot be in the top or second level domain. Matching is done using an
endsWith check.

For example,
``https://*.example.com`` is allowed,
``https://*-myproject.example.com`` is allowed,
``https://*.sub.example.com`` is not allowed,
``https://*.com`` is not allowed, and
``https://example.*.com`` is not allowed.

``*`` can also be used as a suffix to a path, must be the last character in the path.
Matching is done using a startsWith check.

For example,
``https://example.com/*`` is allowed, and
``https://example.com/path/*`` is allowed.

This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch
deployments for development previews and user acceptance testing.

ALLOWED_SCHEMES
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
60 changes: 37 additions & 23 deletions oauth2_provider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,12 @@ def clean(self):

if redirect_uris:
validator = AllowedURIValidator(
allowed_schemes, name="redirect uri", allow_path=True, allow_query=True
allowed_schemes,
name="redirect uri",
allow_path=True,
allow_query=True,
allow_hostname_wildcard=oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS,
allow_path_wildcard=oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS,
)
for uri in redirect_uris:
validator(uri)
Expand Down Expand Up @@ -782,36 +787,44 @@ def redirect_to_uri_allowed(uri, allowed_uris):
for allowed_uri in allowed_uris:
parsed_allowed_uri = urlparse(allowed_uri)

if parsed_allowed_uri.scheme != parsed_uri.scheme:
# match failed, continue
continue

""" check hostname """
if parsed_allowed_uri.hostname.startswith("*"):
""" wildcard hostname """
if not parsed_uri.hostname.endswith(parsed_allowed_uri.hostname[1:]):
continue
elif parsed_allowed_uri.hostname != parsed_uri.hostname:
continue

# From RFC 8252 (Section 7.3)
# https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
#
# Loopback redirect URIs use the "http" scheme
# [...]
# The authorization server MUST allow any port to be specified at the
# time of the request for loopback IP redirect URIs, to accommodate
# clients that obtain an available ephemeral port from the operating
# system at the time of the request.

allowed_uri_is_loopback = (
parsed_allowed_uri.scheme == "http"
and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"]
and parsed_allowed_uri.port is None
)
if (
allowed_uri_is_loopback
and parsed_allowed_uri.scheme == parsed_uri.scheme
and parsed_allowed_uri.hostname == parsed_uri.hostname
and parsed_allowed_uri.path == parsed_uri.path
) or (
parsed_allowed_uri.scheme == parsed_uri.scheme
and parsed_allowed_uri.netloc == parsed_uri.netloc
and parsed_allowed_uri.path == parsed_uri.path
):
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
if aqs_set.issubset(uqs_set):
return True

return False

allowed_uri_is_loopback = (parsed_allowed_uri.scheme == "http" and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"])
""" check port """
if not allowed_uri_is_loopback and parsed_allowed_uri.port != parsed_uri.port:
continue

""" check path """
if parsed_allowed_uri.path.endswith("*"):
""" wildcard path """
if not parsed_uri.path.startswith(parsed_allowed_uri.path[:-1]):
continue
elif parsed_allowed_uri.path != parsed_uri.path:
continue

""" check querystring """
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
if not aqs_set.issubset(uqs_set):
continue # circuit break

def is_origin_allowed(origin, allowed_origins):
"""
Expand All @@ -833,4 +846,5 @@ def is_origin_allowed(origin, allowed_origins):
and parsed_allowed_origin.netloc == parsed_origin.netloc
):
return True

return False
1 change: 1 addition & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"REQUEST_APPROVAL_PROMPT": "force",
"ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"],
"ALLOWED_SCHEMES": ["https"],
"ALLOW_REDIRECT_URI_WILDCARDS": False,
"OIDC_ENABLED": False,
"OIDC_ISS_ENDPOINT": "",
"OIDC_USERINFO_ENDPOINT": "",
Expand Down
61 changes: 59 additions & 2 deletions oauth2_provider/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ class URIValidator(URLValidator):
class AllowedURIValidator(URIValidator):
# TODO: find a way to get these associated with their form fields in place of passing name
# TODO: submit PR to get `cause` included in the parent class ValidationError params`
def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False):
def __init__(
self,
schemes,
name,
allow_path=False,
allow_query=False,
allow_fragments=False,
allow_hostname_wildcard=False,
allow_path_wildcard=False,
):
"""
:param schemes: List of allowed schemes. E.g.: ["https"]
:param name: Name of the validated URI. It is required for validation message. E.g.: "Origin"
Expand All @@ -34,6 +43,8 @@ def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fra
self.allow_path = allow_path
self.allow_query = allow_query
self.allow_fragments = allow_fragments
self.allow_hostname_wildcard = allow_hostname_wildcard or True
self.allow_path_wildcard = allow_path_wildcard or True

def __call__(self, value):
value = force_str(value)
Expand Down Expand Up @@ -68,8 +79,54 @@ def __call__(self, value):
params={"name": self.name, "value": value, "cause": "path not allowed"},
)

if self.allow_hostname_wildcard and "*" in netloc:
domain_parts = netloc.split(".")
if netloc.count("*") > 1:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "only one wildcard is allowed in the hostname"},
)
if not netloc.startswith("*"):
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "wildcards must be at the beginning of the hostname"},
)
if len(domain_parts) < 3:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "wildcards cannot be in the top level or second level domain"},
)

# strip the wildcard from the netloc, we'll reassamble the value later to pass to URI Validator
if netloc.startswith("*."):
netloc = netloc[2:]
else:
netloc = netloc[1:]

if self.allow_path_wildcard and "*" in path:
if path.count("*") > 1:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "only one wildcard is allowed in the path"},
)
if not path.endswith("*"):
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "wildcards must be at the end of the path"},
)
# strip the wildcard from the path, we'll reassamble the value later to pass to URI Validator
path = path[:-1]

# we stripped the wildcard from the netloc and path if they were allowed and present since they would
# fail validation we'll reassamble the URI to pass to the URIValidator
reassambled_uri = f"{scheme}://{netloc}{path}"
if query:
reassambled_uri += f"?{query}"
if fragment:
reassambled_uri += f"#{fragment}"

try:
super().__call__(value)
super().__call__(reassambled_uri)
except ValidationError as e:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
Expand Down

0 comments on commit c2b9ea5

Please sign in to comment.