Skip to content

Commit 7ef5a89

Browse files
authored
Support redirect on absolute pathes without domain name (#105)
We send Emails with URLs without a domain name or schema. There should be no harm in redirecting them, since we can assume that they are to the safe request domain.
1 parent 335ea23 commit 7ef5a89

File tree

2 files changed

+53
-25
lines changed

2 files changed

+53
-25
lines changed

emark/views.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from urllib.parse import urlparse
23

34
from django import http
@@ -8,6 +9,8 @@
89

910
from . import models
1011

12+
logger = logging.getLogger(__name__)
13+
1114
# white 1x1 pixel JPEG in bytes:
1215
#
1316
# import io
@@ -44,30 +47,39 @@ class EmailClickView(SingleObjectMixin, View):
4447
def get(self, request, *args, **kwargs):
4548
self.object = self.get_object()
4649

47-
redirect_to = request.GET.get("url")
48-
# The redirect_to URL is user-provided, so it might be malicious
49-
# or malformed. We use Django's URL validation to ensure that it
50-
# is safe to redirect to.
50+
try:
51+
redirect_to = request.GET["url"]
52+
except KeyError:
53+
logger.warning(
54+
"Missing url parameter", extra={"request": request}, exc_info=True
55+
)
56+
return http.HttpResponseBadRequest("Missing url parameter")
5157
parsed_url = urlparse(redirect_to)
52-
if not parsed_url.netloc:
53-
return http.HttpResponseBadRequest("Missing url or malformed parameter")
54-
55-
domain, _port = split_domain_port(parsed_url.netloc)
56-
allowed_hosts = settings.ALLOWED_HOSTS
57-
if settings.DEBUG:
58-
allowed_hosts = settings.ALLOWED_HOSTS + [
59-
".localhost",
60-
"127.0.0.1",
61-
"[::1]",
62-
]
63-
if any(
64-
[
65-
not domain,
66-
not validate_host(domain, allowed_hosts),
67-
request.scheme != parsed_url.scheme,
68-
]
69-
):
70-
return http.HttpResponseBadRequest("Missing url or malformed parameter")
58+
if parsed_url.netloc:
59+
# The redirect_to URL is user-provided, so it might be malicious
60+
# or malformed. We use Django's URL validation to ensure that it
61+
# is safe to redirect to.
62+
domain, _port = split_domain_port(parsed_url.netloc)
63+
allowed_hosts = settings.ALLOWED_HOSTS
64+
if settings.DEBUG:
65+
allowed_hosts = settings.ALLOWED_HOSTS + [
66+
".localhost",
67+
"127.0.0.1",
68+
"[::1]",
69+
]
70+
if any(
71+
[
72+
not domain,
73+
not validate_host(domain, allowed_hosts),
74+
request.scheme != parsed_url.scheme,
75+
]
76+
):
77+
logger.warning(
78+
"Malformed URL parameter: %s",
79+
redirect_to,
80+
extra={"request": request},
81+
)
82+
return http.HttpResponseBadRequest("Malformed url parameter")
7183

7284
models.Click.objects.create_for_request(
7385
request, email=self.object, redirect_url=redirect_to

tests/test_views.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ def test_get__no_email(self, client):
3434

3535
class TestEmailClickView:
3636
@pytest.mark.django_db
37-
def test_get__no_redirect_url(self, client):
37+
def test_get__no_redirect_url(self, client, caplog):
3838
msg = baker.make("emark.Send")
3939
response = client.get(reverse("emark:email-click", kwargs={"pk": msg.pk}))
4040
assert response.status_code == 400
41+
assert response.content == b"Missing url parameter"
42+
assert "Missing url parameter" in caplog.text
4143

4244
@pytest.mark.django_db
43-
def test_get__unsafe_redirect_url(self, client, live_server):
45+
def test_get__unsafe_redirect_url(self, client, live_server, caplog):
4446
msg = baker.make("emark.Send")
4547
redirect_url = "http://external-domain.com/?utm_source=foo"
4648

@@ -49,6 +51,8 @@ def test_get__unsafe_redirect_url(self, client, live_server):
4951
url = f"{url}?{urlencode({'url': redirect_url})}"
5052
response = client.get(url)
5153
assert response.status_code == 400
54+
assert response.content == b"Malformed url parameter"
55+
assert "Malformed URL parameter" in caplog.text
5256

5357
@pytest.mark.django_db
5458
def test_get__different_schema_redirect_url(self, client, live_server):
@@ -73,6 +77,18 @@ def test_get__subdomain_redirect_url(self, client, live_server, settings):
7377
response = client.get(url)
7478
assert response.status_code == 302
7579

80+
@pytest.mark.django_db
81+
def test_get__absolute_path(self, client, live_server, settings):
82+
settings.ALLOWED_HOSTS = ["testserver", ".testserver"]
83+
msg = baker.make("emark.Send")
84+
redirect_url = "/some/path?utm_source=foo"
85+
86+
url = reverse("emark:email-click", kwargs={"pk": msg.pk})
87+
88+
url = f"{url}?{urlencode({'url': redirect_url})}"
89+
response = client.get(url)
90+
assert response.status_code == 302
91+
7692
@pytest.mark.django_db
7793
def test_get__subdomain_debug(self, client, live_server, settings):
7894
settings.DEBUG = True

0 commit comments

Comments
 (0)