Skip to content

Commit 97dc0e8

Browse files
committed
WIP: feat: redirect uri wildcards
1 parent b48fd8b commit 97dc0e8

File tree

7 files changed

+439
-25
lines changed

7 files changed

+439
-25
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: 44 additions & 23 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,11 @@ 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(
235+
oauth2_settings.ALLOWED_SCHEMES,
236+
"allowed origin",
237+
allow_hostname_wildcard=oauth2_settings.ALLOW_REDIRECT_URI_WILDCARDS,
238+
)
231239
for uri in allowed_origins:
232240
validator(uri)
233241

@@ -782,35 +790,47 @@ def redirect_to_uri_allowed(uri, allowed_uris):
782790
for allowed_uri in allowed_uris:
783791
parsed_allowed_uri = urlparse(allowed_uri)
784792

793+
if parsed_allowed_uri.scheme != parsed_uri.scheme:
794+
# match failed, continue
795+
continue
796+
797+
""" check hostname """
798+
if parsed_allowed_uri.hostname.startswith("*"):
799+
""" wildcard hostname """
800+
if not parsed_uri.hostname.endswith(parsed_allowed_uri.hostname[1:]):
801+
continue
802+
elif parsed_allowed_uri.hostname != parsed_uri.hostname:
803+
continue
804+
785805
# From RFC 8252 (Section 7.3)
806+
# https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
786807
#
787808
# Loopback redirect URIs use the "http" scheme
788809
# [...]
789810
# The authorization server MUST allow any port to be specified at the
790811
# time of the request for loopback IP redirect URIs, to accommodate
791812
# clients that obtain an available ephemeral port from the operating
792813
# system at the time of the request.
793-
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
812-
813-
return False
814+
allowed_uri_is_loopback = parsed_allowed_uri.scheme == "http" and parsed_allowed_uri.hostname in [
815+
"127.0.0.1",
816+
"::1",
817+
]
818+
""" check port """
819+
if not allowed_uri_is_loopback and parsed_allowed_uri.port != parsed_uri.port:
820+
continue
821+
822+
""" check path """
823+
if parsed_allowed_uri.path.endswith("*"):
824+
""" wildcard path """
825+
if not parsed_uri.path.startswith(parsed_allowed_uri.path[:-1]):
826+
continue
827+
elif parsed_allowed_uri.path != parsed_uri.path:
828+
continue
829+
830+
""" check querystring """
831+
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
832+
if not aqs_set.issubset(uqs_set):
833+
continue # circuit break
814834

815835

816836
def is_origin_allowed(origin, allowed_origins):
@@ -833,4 +853,5 @@ def is_origin_allowed(origin, allowed_origins):
833853
and parsed_allowed_origin.netloc == parsed_origin.netloc
834854
):
835855
return True
856+
836857
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: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ class URIValidator(URLValidator):
2121
class AllowedURIValidator(URIValidator):
2222
# TODO: find a way to get these associated with their form fields in place of passing name
2323
# 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):
24+
def __init__(
25+
self,
26+
schemes,
27+
name,
28+
allow_path=False,
29+
allow_query=False,
30+
allow_fragments=False,
31+
allow_hostname_wildcard=False,
32+
):
2533
"""
2634
:param schemes: List of allowed schemes. E.g.: ["https"]
2735
:param name: Name of the validated URI. It is required for validation message. E.g.: "Origin"
@@ -34,6 +42,7 @@ def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fra
3442
self.allow_path = allow_path
3543
self.allow_query = allow_query
3644
self.allow_fragments = allow_fragments
45+
self.allow_hostname_wildcard = allow_hostname_wildcard
3746

3847
def __call__(self, value):
3948
value = force_str(value)
@@ -68,8 +77,57 @@ def __call__(self, value):
6877
params={"name": self.name, "value": value, "cause": "path not allowed"},
6978
)
7079

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

tests/test_application_views.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,151 @@ def test_application_registration_user(self):
6363
self.assertEqual(app.algorithm, form_data["algorithm"])
6464

6565

66+
@pytest.mark.usefixtures("oauth2_settings")
67+
@pytest.mark.oauth2_settings({"ALLOW_REDIRECT_URI_WILDCARDS": True})
68+
class TestApplicationRegistrationViewRedirectURIWithWildcardRedirectURIs(BaseTest):
69+
def _test_valid(self, redirect_uri):
70+
self.client.login(username="foo_user", password="123456")
71+
72+
form_data = {
73+
"name": "Foo app",
74+
"client_id": "client_id",
75+
"client_secret": "client_secret",
76+
"client_type": Application.CLIENT_CONFIDENTIAL,
77+
"redirect_uris": redirect_uri,
78+
"post_logout_redirect_uris": "http://example.com",
79+
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
80+
"algorithm": "",
81+
}
82+
83+
response = self.client.post(reverse("oauth2_provider:register"), form_data)
84+
self.assertEqual(response.status_code, 302)
85+
86+
app = get_application_model().objects.get(name="Foo app")
87+
self.assertEqual(app.user.username, "foo_user")
88+
app = Application.objects.get()
89+
self.assertEqual(app.name, form_data["name"])
90+
self.assertEqual(app.client_id, form_data["client_id"])
91+
self.assertEqual(app.redirect_uris, form_data["redirect_uris"])
92+
self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"])
93+
self.assertEqual(app.client_type, form_data["client_type"])
94+
self.assertEqual(app.authorization_grant_type, form_data["authorization_grant_type"])
95+
self.assertEqual(app.algorithm, form_data["algorithm"])
96+
97+
def _test_invalid(self, uri, error_message):
98+
self.client.login(username="foo_user", password="123456")
99+
100+
form_data = {
101+
"name": "Foo app",
102+
"client_id": "client_id",
103+
"client_secret": "client_secret",
104+
"client_type": Application.CLIENT_CONFIDENTIAL,
105+
"redirect_uris": uri,
106+
"post_logout_redirect_uris": "http://example.com",
107+
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
108+
"algorithm": "",
109+
}
110+
111+
response = self.client.post(reverse("oauth2_provider:register"), form_data)
112+
self.assertEqual(response.status_code, 400)
113+
self.assertContains(response, error_message)
114+
115+
def test_application_registration_valid_3ld_wildcard(self):
116+
self._test_valid("http://*.example.com")
117+
118+
def test_application_registration_valid_3ld_partial_wildcard(self):
119+
self._test_valid("http://*-partial.example.com")
120+
121+
def test_application_registration_invalid_tld_wildcard(self):
122+
self._test_invalid("http://*", "Wildcard redirect URIs must be at least 3 levels deep")
123+
124+
def test_application_registration_invalid_tld_partial_wildcard(self):
125+
self._test_invalid("http://*-partial", "Wildcard redirect URIs must be at least 3 levels deep")
126+
127+
def test_application_registration_invalid_tld_not_startswith_wildcard_tld(self):
128+
self._test_invalid("http://example.*", "Wildcard redirect URIs must start with a wildcard character")
129+
130+
def test_application_registration_invalid_2ld_wildcard(self):
131+
self._test_invalid("http://*.com", "Wildcard redirect URIs must be at least 3 levels deep")
132+
133+
def test_application_registration_invalid_2ld_partial_wildcard(self):
134+
self._test_invalid("http://*-partial.com", "Wildcard redirect URIs must be at least 3 levels deep")
135+
136+
def test_application_registration_invalid_2ld_not_startswith_wildcard_tld(self):
137+
self._test_invalid(
138+
"http://example.*.com", "Wildcard redirect URIs must start with a wildcard character"
139+
)
140+
141+
def test_application_registration_invalid_3ld_partial_not_startswith_wildcard_2ld(self):
142+
self._test_invalid(
143+
"http://invalid-*.example.com", "Wildcard redirect URIs must start with a wildcard character"
144+
)
145+
146+
def test_application_registration_invalid_4ld_not_startswith_wildcard_3ld(self):
147+
self._test_invalid(
148+
"http://invalid/.*.invalid.example.com",
149+
"Wildcard redirect URIs must start with a wildcard character",
150+
)
151+
152+
def test_application_registration_invalid_4ld_partial_not_startswith_wildcard_2ld(self):
153+
self._test_invalid(
154+
"http://invalid-*.invalid.example.com",
155+
"Wildcard redirect URIs must start with a wildcard character",
156+
)
157+
158+
159+
@pytest.mark.usefixtures("oauth2_settings")
160+
@pytest.mark.oauth2_settings({"ALLOW_REDIRECT_URI_WILDCARDS": True})
161+
class TestApplicationRegistrationViewPostLogoutRedirectURIWithWildcardRedirectURIs(
162+
TestApplicationRegistrationViewRedirectURIWithWildcardRedirectURIs
163+
):
164+
def _test_valid(self, redirect_uri):
165+
self.client.login(username="foo_user", password="123456")
166+
167+
form_data = {
168+
"name": "Foo app",
169+
"client_id": "client_id",
170+
"client_secret": "client_secret",
171+
"client_type": Application.CLIENT_CONFIDENTIAL,
172+
"redirect_uris": "http://example.com",
173+
"post_logout_redirect_uris": redirect_uri,
174+
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
175+
"algorithm": "",
176+
}
177+
178+
response = self.client.post(reverse("oauth2_provider:register"), form_data)
179+
self.assertEqual(response.status_code, 302)
180+
181+
app = get_application_model().objects.get(name="Foo app")
182+
self.assertEqual(app.user.username, "foo_user")
183+
app = Application.objects.get()
184+
self.assertEqual(app.name, form_data["name"])
185+
self.assertEqual(app.client_id, form_data["client_id"])
186+
self.assertEqual(app.redirect_uris, form_data["redirect_uris"])
187+
self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"])
188+
self.assertEqual(app.client_type, form_data["client_type"])
189+
self.assertEqual(app.authorization_grant_type, form_data["authorization_grant_type"])
190+
self.assertEqual(app.algorithm, form_data["algorithm"])
191+
192+
def _test_invalid(self, uri, error_message):
193+
self.client.login(username="foo_user", password="123456")
194+
195+
form_data = {
196+
"name": "Foo app",
197+
"client_id": "client_id",
198+
"client_secret": "client_secret",
199+
"client_type": Application.CLIENT_CONFIDENTIAL,
200+
"redirect_uris": "http://example.com",
201+
"post_logout_redirect_uris": uri,
202+
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
203+
"algorithm": "",
204+
}
205+
206+
response = self.client.post(reverse("oauth2_provider:register"), form_data)
207+
self.assertEqual(response.status_code, 400)
208+
self.assertContains(response, error_message)
209+
210+
66211
class TestApplicationViews(BaseTest):
67212
@classmethod
68213
def _create_application(cls, name, user):

0 commit comments

Comments
 (0)