Reserved URL characters (#, ?) in decoded path params corrupt request.url and url_path_for() #3367
Replies: 4 comments
|
This looks like a real bug, and the distinction you’re drawing is the important one: from urllib.parse import quote
raw_path = scope.get("raw_path")
if raw_path is not None:
path = raw_path.decode("ascii")
else:
path = quote(scope["path"], safe="/%")The exact client.get("/foo/a%23b%3Fc=1")continues to route with: request.path_params["name"] == "a#b?c=1"while also producing: request.url.path == "/foo/a#b?c=1"
request.url.fragment == ""
str(request.url) == "http://testserver/foo/a%23b%3Fc=1"The outbound side is the same category of issue, but it should be fixed in route generation rather than in from urllib.parse import quote
value = convertor.to_string(value)
if convertor is path_convertor:
value = quote(value, safe="/")
else:
value = quote(value, safe="")
path = path.replace("{" + key + "}", value)That is intentionally illustrative rather than the final patch, because Starlette’s convertor API may need a cleaner way to express “characters safe for URL generation” per convertor. The important behavior is that If my answer solved your problem, you can click answered the question. I'm really here to help, and along the way I'm also collecting Galaxy Brain badges haha 😆 |
|
Sorry, I'm just passing by here, haha, but I wanted to say: this is a really thoughtful and useful report. Thanks for taking the time to dig into the details and explain it clearly. Keep going — cheering you on! |
|
Thank youu, just starting out on open source 🥲 |
Uh oh!
There was an error while loading. Please reload this page.
Description
ASGI delivers
scope["path"]already percent-decoded. Starlette uses that decoded string directly in two places without re-encoding reserved URL characters (#,?), which means a literal#or?in a decoded path segment gets reinterpreted as URL syntax (fragment/query separator) instead of staying part of the path.1. Inbound:
request.url/request.url.pathstarlette/datastructures.py,URL.__init__:SplitResult.geturl() does no escaping — it just joins the components with their normal separators. If path contains a literal #, it becomes the start of the fragment when the resulting string is later parsed (e.g. by .path/.fragment property access via urlsplit), even though the router matched against the correct, un-truncated value.
2. Outbound: url_path_for() / Router.url_path_for()
starlette/routing.py:92-103 (replace_params) splices the converted parameter string directly into the path template with no escaping:
StringConvertor.to_string / PathConvertor.to_string (starlette/convertors.py:26-30, 39-40) do no encoding either — they just return str(value). So a path param value containing # or ? produces a URL string that, if actually dereferenced, resolves to a different value than what was passed to url_for.
Example
Why this matters
building canonical links/redirects — a silently truncated value there is a
correctness issue, not just cosmetic.
handling (URL(scope=redirect_scope)), so a Location header built from
it can point at the wrong resource.
for is a fairly sharp footgun for anyone building links from user-supplied
path segments.
Environment
I think the fix is to percent-encode reserved characters (at minimum #, ?, and probably % itself for round-tripping) when: (a) building the URL string from scope["path"] in datastructures.py, and (b) substituting converted values into the path template in replace_params. Wanted to raise this as a discussion first since it touches core URL construction and I'd like input on the right place/approach before attempting a PR, e.g. whether encoding belongs in Convertor.to_string() per-convertor, or centrally in replace_params/URL.
All reactions