Dynamic routes with percent-encodable fixed segments (space, non-ASCII) are unreachable and url_for() URLs 404
- Ngôn ngữ chính
- Python
- Star
- 16.5k
- Fork
- 2.4k
- Merge trung bình
- 17 giờ 22 phút
- Pull request đã merge (30 ngày)
- 212
Mô tả
### Describe the bug
Dynamic routes (and static/prefix resources) whose fixed path segment contains characters that need percent-encoding — a space, non-ASCII, or some reserved characters — are unreachable: every request to them returns **404**, and the URL produced by `url_for()` cannot be routed back either.
The fixed part is percent-encoded once at registration (`_requote_path`) and the encoded form is used **both** for the resource-index key and the match pattern. But `UrlDispatcher.resolve` selects candidates by walking the **decoded** `request.rel_url.path_safe` backwards. The encoded index key is therefore never probed, so the resource is never a candidate.
Example: `app.router.add_get("/hello world/{name}", handler, name="greet")` compiles to formatter `/hello%20world/{name}` and index key `/hello%20world`. A request for `/hello%20world/john` decodes to `/hello world/john`, and the backward walk probes `/hello world/john`, `/hello world`, `/` — never `/hello%20world` — so it 404s. The same applies to `StaticResource` prefixes (`app.router.add_static("/static files/", ...)` is unreachable over `/static%20files/...`).
Verified on aiohttp 3.14.3 and on master.
Relevant code (aiohttp 3.14.3, `aiohttp/web_urldispatcher.py`):
- `DynamicResource.__init__` encodes fixed parts and uses the encoded form for both formatter and pattern literal: `part = _requote_path(part)` (:457) and `pattern += re.escape(part)` (:459); `self._formatter = formatter` (:468).
- `DynamicResource.canonical` returns that encoded formatter (:471-472).
- `_get_resource_index_key` derives the index key from the encoded canonical (:1110-1119): for `/hello%20world/{name}` the key is `/hello%20world`.
- `resolve` walks the **decoded** path backwards (`url_part = request.rel_url.path_safe` :1033; loop :1034-1043) and 404s when no candidate matches (:1048).
### To Reproduce
```python
import asyncio
from aiohttp import web
async def main() -> None:
app = web.Application()
# dynamic route with a percent-encodable fixed segment (space)
app.router.add_get("/hello world/{name}", lambda r: web.Response(text=r.match_info["name"]), name="greet")
# plain route with the same fixed segment (control: works)
app.router.add_get("/hello world/", lambda r: web.Response(text="plain"))
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 8080)
await site.start()
async def get(path: str) -> None:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(f"http://127.0.0.1:8080{path}") as resp:
print(f"GET {path:24} -> {resp.status}")
await get("/hello%20world/") # plain route -> 200
await get("/hello%20world/john") # dynamic route -> 404 (expected 200)
await get(str(app.router["greet"].url_for(name="john"))) # url_for -> 404
await runner.cleanup()
asyncio.run(main())
```
Output:
```console
GET /hello%20world/ -> 200
GET /hello%20world/john -> 404
GET /hello%20world/john -> 404
```
### Expected behavior
A dynamic route registered as `/hello world/{name}` must serve requests to the encoded URL `/hello%20world/{name}` (that is what `url_for()` produces), matching the decoded-path semantics the router uses everywhere else. The plain route with the same fixed segment resolves fine, so such characters are clearly supported — only the dynamic/static registration path is broken.
### Logs/tracebacks
```python-traceback
No traceback; the requests just return 404. Full self-checking repro with controls (also covers `StaticResource`):
PASS - c1: plain route '/hello world/' resolves over %20
PASS - c2: dynamic route without encodable chars resolves
FAIL - a3: StaticResource over '/static files/' resolves -> 200 (got 404)
FAIL - a1: dynamic '/hello world/{name}' matches -> 200 (got 404)
FAIL - a2: url_for-generated URL resolves back -> 200 (got 404)
```
### Python Version
```console
$ python --version
Python 3.10.12
```
### aiohttp Version
```console
$ python -m pip show aiohttp
Name: aiohttp
Version: 3.14.3 (also reproduced on master @ 356d355b7)
```
### multidict Version
```console
$ python -m pip show multidict
Version: 6.7.1
```
### propcache Version
```console
$ python -m pip show propcache
Version: 0.5.2
```
### yarl Version
```console
$ python -m pip show yarl
Version: 1.24.5
```
### OS
Linux
### Related component
Server
### Additional context
The mismatch is between the encoded canonical/index key (registration side) and the decoded `path_safe` traversal (resolution side). Direction for a fix: `resolve`/`_get_resource_index_key` must agree on one representation. Since the router already matches on the decoded path everywhere else (`Resource.resolve`, `StaticResource.resolve`, and the `path_safe` walk), the index key should be derived from the decoded path too (e.g. keep the raw fixed segment for the key), or the backward walk should also probe the encoded forms. `url_for`/resolve round-tripping is the documented contract (`docs/web_quickstart.rst`).
### Code of Conduct
- [x] I agree to follow the aio-libs Code of Conduct
Hướng dẫn đóng góp
Đánh giá
Issue này chưa được đánh giá.