Skip to content

Commit 0e3b73c

Browse files
committed
WIP: feat: redirect uri wildcards
1 parent b48fd8b commit 0e3b73c

File tree

5 files changed

+136
-23
lines changed

5 files changed

+136
-23
lines changed

docs/settings.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ assigned ports.
6363
Note that you may override ``Application.get_allowed_schemes()`` to set this on
6464
a per-application basis.
6565

66+
ALLOW_REDIRECT_URI_WILDCARDS
67+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
68+
69+
Default: ``False``
70+
71+
SECURITY WARNING: Enabling this setting can introduce security vulnerabilities. Only enable
72+
this setting if you understand the risks. https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
73+
states "The redirection endpoint URI MUST be an absolute URI as defined by [RFC3986] Section 4.3." The
74+
intent of the URI restrictions is to prevent open redirects and phishing attacks. If you do enable this
75+
ensure that the wildcards restrict URIs to resources under your control. You are strongly encouragd not
76+
to use this feature in production.
77+
78+
When set to ``True``, the server will allow wildcard characters in the domains
79+
and paths for redirect_uris and post_logout_redirect_uris.
80+
81+
``*`` is the only wildcard character allowed.
82+
83+
``*`` can only be used as a prefix to a domain, must be the first character in
84+
the domain, and cannot be in the top or second level domain. Matching is done using an
85+
endsWith check.
86+
87+
For example,
88+
``https://*.example.com`` is allowed,
89+
``https://*-myproject.example.com`` is allowed,
90+
``https://*.sub.example.com`` is not allowed,
91+
``https://*.com`` is not allowed, and
92+
``https://example.*.com`` is not allowed.
93+
94+
``*`` can also be used as a suffix to a path, must be the last character in the path.
95+
Matching is done using a startsWith check.
96+
97+
For example,
98+
``https://example.com/*`` is allowed, and
99+
``https://example.com/path/*`` is allowed.
100+
101+
This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch
102+
deployments for development previews and user acceptance testing.
103+
66104
ALLOWED_SCHEMES
67105
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
68106

oauth2_provider/models.py

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ def clean(self):
213213

214214
if redirect_uris:
215215
validator = AllowedURIValidator(
216-
allowed_schemes, name="redirect uri", allow_path=True, allow_query=True
216+
allowed_schemes,
217+
name="redirect uri",
218+
allow_path=True,
219+
allow_query=True,
220+
allow_hostname_wildcard=oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS,
217221
)
218222
for uri in redirect_uris:
219223
validator(uri)
@@ -227,7 +231,7 @@ def clean(self):
227231
allowed_origins = self.allowed_origins.strip().split()
228232
if allowed_origins:
229233
# oauthlib allows only https scheme for CORS
230-
validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "allowed origin")
234+
validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "allowed origin", allow_hostname_wildcard=oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS)
231235
for uri in allowed_origins:
232236
validator(uri)
233237

@@ -782,35 +786,43 @@ def redirect_to_uri_allowed(uri, allowed_uris):
782786
for allowed_uri in allowed_uris:
783787
parsed_allowed_uri = urlparse(allowed_uri)
784788

789+
if parsed_allowed_uri.scheme != parsed_uri.scheme:
790+
# match failed, continue
791+
continue
792+
793+
""" check hostname """
794+
if oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS and parsed_allowed_uri.hostname.startswith("*"):
795+
""" wildcard hostname """
796+
if not parsed_uri.hostname.endswith(parsed_allowed_uri.hostname[1:]):
797+
continue
798+
elif parsed_allowed_uri.hostname != parsed_uri.hostname:
799+
continue
800+
785801
# From RFC 8252 (Section 7.3)
802+
# https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
786803
#
787804
# Loopback redirect URIs use the "http" scheme
788805
# [...]
789806
# The authorization server MUST allow any port to be specified at the
790807
# time of the request for loopback IP redirect URIs, to accommodate
791808
# clients that obtain an available ephemeral port from the operating
792809
# system at the time of the request.
810+
allowed_uri_is_loopback = parsed_allowed_uri.scheme == "http" and parsed_allowed_uri.hostname in [
811+
"127.0.0.1",
812+
"::1",
813+
]
814+
""" check port """
815+
if not allowed_uri_is_loopback and parsed_allowed_uri.port != parsed_uri.port:
816+
continue
793817

794-
allowed_uri_is_loopback = (
795-
parsed_allowed_uri.scheme == "http"
796-
and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"]
797-
and parsed_allowed_uri.port is None
798-
)
799-
if (
800-
allowed_uri_is_loopback
801-
and parsed_allowed_uri.scheme == parsed_uri.scheme
802-
and parsed_allowed_uri.hostname == parsed_uri.hostname
803-
and parsed_allowed_uri.path == parsed_uri.path
804-
) or (
805-
parsed_allowed_uri.scheme == parsed_uri.scheme
806-
and parsed_allowed_uri.netloc == parsed_uri.netloc
807-
and parsed_allowed_uri.path == parsed_uri.path
808-
):
809-
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
810-
if aqs_set.issubset(uqs_set):
811-
return True
818+
""" check path """
819+
if parsed_allowed_uri.path != parsed_uri.path:
820+
continue
812821

813-
return False
822+
""" check querystring """
823+
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
824+
if not aqs_set.issubset(uqs_set):
825+
continue # circuit break
814826

815827

816828
def is_origin_allowed(origin, allowed_origins):
@@ -833,4 +845,5 @@ def is_origin_allowed(origin, allowed_origins):
833845
and parsed_allowed_origin.netloc == parsed_origin.netloc
834846
):
835847
return True
848+
836849
return False

oauth2_provider/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"REQUEST_APPROVAL_PROMPT": "force",
7272
"ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"],
7373
"ALLOWED_SCHEMES": ["https"],
74+
"ALLOW_REDIRECT_URI_WILDCARDS": False,
7475
"OIDC_ENABLED": False,
7576
"OIDC_ISS_ENDPOINT": "",
7677
"OIDC_USERINFO_ENDPOINT": "",

oauth2_provider/validators.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from django.core.validators import URLValidator
66
from django.utils.encoding import force_str
77

8+
from .settings import oauth2_settings
9+
810

911
class URIValidator(URLValidator):
1012
scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://"
@@ -21,7 +23,15 @@ class URIValidator(URLValidator):
2123
class AllowedURIValidator(URIValidator):
2224
# TODO: find a way to get these associated with their form fields in place of passing name
2325
# TODO: submit PR to get `cause` included in the parent class ValidationError params`
24-
def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False):
26+
def __init__(
27+
self,
28+
schemes,
29+
name,
30+
allow_path=False,
31+
allow_query=False,
32+
allow_fragments=False,
33+
allow_hostname_wildcard=False,
34+
):
2535
"""
2636
:param schemes: List of allowed schemes. E.g.: ["https"]
2737
:param name: Name of the validated URI. It is required for validation message. E.g.: "Origin"
@@ -34,6 +44,7 @@ def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fra
3444
self.allow_path = allow_path
3545
self.allow_query = allow_query
3646
self.allow_fragments = allow_fragments
47+
self.allow_hostname_wildcard = allow_hostname_wildcard
3748

3849
def __call__(self, value):
3950
value = force_str(value)
@@ -68,8 +79,56 @@ def __call__(self, value):
6879
params={"name": self.name, "value": value, "cause": "path not allowed"},
6980
)
7081

82+
if (
83+
oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS
84+
and self.allow_hostname_wildcard
85+
and "*" in netloc
86+
):
87+
domain_parts = netloc.split(".")
88+
if netloc.count("*") > 1:
89+
raise ValidationError(
90+
"%(name)s URI validation error. %(cause)s: %(value)s",
91+
params={
92+
"name": self.name,
93+
"value": value,
94+
"cause": "only one wildcard is allowed in the hostname",
95+
},
96+
)
97+
if not netloc.startswith("*"):
98+
raise ValidationError(
99+
"%(name)s URI validation error. %(cause)s: %(value)s",
100+
params={
101+
"name": self.name,
102+
"value": value,
103+
"cause": "wildcards must be at the beginning of the hostname",
104+
},
105+
)
106+
if len(domain_parts) < 3:
107+
raise ValidationError(
108+
"%(name)s URI validation error. %(cause)s: %(value)s",
109+
params={
110+
"name": self.name,
111+
"value": value,
112+
"cause": "wildcards cannot be in the top level or second level domain",
113+
},
114+
)
115+
116+
# strip the wildcard from the netloc, we'll reassamble the value later to pass to URI Validator
117+
if netloc.startswith("*."):
118+
netloc = netloc[2:]
119+
else:
120+
netloc = netloc[1:]
121+
122+
# we stripped the wildcard from the netloc and path if they were allowed and present since they would
123+
# fail validation we'll reassamble the URI to pass to the URIValidator
124+
reassambled_uri = f"{scheme}://{netloc}{path}"
125+
if query:
126+
reassambled_uri += f"?{query}"
127+
if fragment:
128+
reassambled_uri += f"#{fragment}"
129+
71130
try:
72-
super().__call__(value)
131+
super().__call__(reassambled_uri)
73132
except ValidationError as e:
74133
raise ValidationError(
75134
"%(name)s URI validation error. %(cause)s: %(value)s",

tests/test_oidc_views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ def test_validate_logout_request(oidc_tokens, public_application, rp_settings):
299299
post_logout_redirect_uri="http://other.org",
300300
)
301301

302+
# TODO: test wildcards
303+
302304

303305
@pytest.mark.django_db(databases=retrieve_current_databases())
304306
@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False])

0 commit comments

Comments
 (0)