From 0403a04da69774a89a1de01567dfdfcd94d93a9c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 4 Nov 2025 17:08:06 -0800 Subject: [PATCH] Move open redirect fix to asgi_send_redirect, refs #2429 See https://github.com/simonw/datasette/pull/2500#issuecomment-3488632278 --- datasette/app.py | 5 ----- datasette/utils/asgi.py | 4 ++++ tests/test_custom_pages.py | 5 +++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index c5b65aea..999d6aa6 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1391,11 +1391,6 @@ class DatasetteRouter: context = {} if path.endswith(b"/"): path = path.rstrip(b"/") - - # If you redirect with a // at the beginning, you end up with an open redirect, so - # https://my.site//foo/ - will redirect to https://foo - path = re.sub(rb"^/+", b"/", path) - if request.scope["query_string"]: path += b"?" + request.scope["query_string"] await asgi_send_redirect(send, path.decode("latin1")) diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 16f90077..0f1f98bf 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -6,6 +6,7 @@ from pathlib import Path from http.cookies import SimpleCookie, Morsel import aiofiles import aiofiles.os +import re # Workaround for adding samesite support to pre 3.8 python Morsel._reserved["samesite"] = "SameSite" @@ -227,6 +228,9 @@ async def asgi_send_html(send, html, status=200, headers=None): async def asgi_send_redirect(send, location, status=302): + # Prevent open redirect vulnerability: strip multiple leading slashes + # //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/) + location = re.sub(r"^/+", "/", location) await asgi_send( send, "", diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py index ccc139ce..39a4c06b 100644 --- a/tests/test_custom_pages.py +++ b/tests/test_custom_pages.py @@ -100,6 +100,7 @@ def test_custom_route_pattern_404(custom_pages_client): def test_custom_route_pattern_with_slash_slash_302(custom_pages_client): - response = custom_pages_client.get("//nastyOpenRedirect/") + # https://github.com/simonw/datasette/issues/2429 + response = custom_pages_client.get("//example.com/") assert response.status == 302 - assert response.headers["location"] == "/nastyOpenRedirect" + assert response.headers["location"] == "/example.com"