aio-libs / aio-libs/aiohttp

Dynamic routes with percent-encodable fixed segments (space, non-ASCII) are unreachable and url_for() URLs 404

オープン
#13,433 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る
bug
主要言語
Python
スター
16.5k
フォーク
2.4k
平均マージ
17時間 22分
マージ済み PR(30日)
212

説明

### 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

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。