Skip to content

Commit

Permalink
Support redirect on absolute pathes without domain name (#105)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
codingjoe authored Sep 10, 2024
1 parent 335ea23 commit 7ef5a89
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 25 deletions.
58 changes: 35 additions & 23 deletions emark/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from urllib.parse import urlparse

from django import http
Expand All @@ -8,6 +9,8 @@

from . import models

logger = logging.getLogger(__name__)

# white 1x1 pixel JPEG in bytes:
#
# import io
Expand Down Expand Up @@ -44,30 +47,39 @@ class EmailClickView(SingleObjectMixin, View):
def get(self, request, *args, **kwargs):
self.object = self.get_object()

redirect_to = request.GET.get("url")
# The redirect_to URL is user-provided, so it might be malicious
# or malformed. We use Django's URL validation to ensure that it
# is safe to redirect to.
try:
redirect_to = request.GET["url"]
except KeyError:
logger.warning(
"Missing url parameter", extra={"request": request}, exc_info=True
)
return http.HttpResponseBadRequest("Missing url parameter")
parsed_url = urlparse(redirect_to)
if not parsed_url.netloc:
return http.HttpResponseBadRequest("Missing url or malformed parameter")

domain, _port = split_domain_port(parsed_url.netloc)
allowed_hosts = settings.ALLOWED_HOSTS
if settings.DEBUG:
allowed_hosts = settings.ALLOWED_HOSTS + [
".localhost",
"127.0.0.1",
"[::1]",
]
if any(
[
not domain,
not validate_host(domain, allowed_hosts),
request.scheme != parsed_url.scheme,
]
):
return http.HttpResponseBadRequest("Missing url or malformed parameter")
if parsed_url.netloc:
# The redirect_to URL is user-provided, so it might be malicious
# or malformed. We use Django's URL validation to ensure that it
# is safe to redirect to.
domain, _port = split_domain_port(parsed_url.netloc)
allowed_hosts = settings.ALLOWED_HOSTS
if settings.DEBUG:
allowed_hosts = settings.ALLOWED_HOSTS + [
".localhost",
"127.0.0.1",
"[::1]",
]
if any(
[
not domain,
not validate_host(domain, allowed_hosts),
request.scheme != parsed_url.scheme,
]
):
logger.warning(
"Malformed URL parameter: %s",
redirect_to,
extra={"request": request},
)
return http.HttpResponseBadRequest("Malformed url parameter")

models.Click.objects.create_for_request(
request, email=self.object, redirect_url=redirect_to
Expand Down
20 changes: 18 additions & 2 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ def test_get__no_email(self, client):

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

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

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

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

@pytest.mark.django_db
def test_get__absolute_path(self, client, live_server, settings):
settings.ALLOWED_HOSTS = ["testserver", ".testserver"]
msg = baker.make("emark.Send")
redirect_url = "/some/path?utm_source=foo"

url = reverse("emark:email-click", kwargs={"pk": msg.pk})

url = f"{url}?{urlencode({'url': redirect_url})}"
response = client.get(url)
assert response.status_code == 302

@pytest.mark.django_db
def test_get__subdomain_debug(self, client, live_server, settings):
settings.DEBUG = True
Expand Down

0 comments on commit 7ef5a89

Please sign in to comment.