Repository navigation
notion: an escape in the path is decoded before routing, so /v1/user%73/me and /v1/users/m%65 are the bot user where real answers a 400 #1127
Description
Found while reviewing #1081.
Source and commit — notion, main at 64c9f027.
How you know — measured against api.notion.com on 2026-10-28 with a token and Notion-Version: 2025-09-03, each path sent as written with http.client:
GET /v1/users/me 200, the bot user
GET /v1%2Fusers/me 400 invalid_request_url
GET /v1/users%2Fme 400 invalid_request_url
GET /%761/users/me 400 invalid_request_url
GET /v%31/users/me 400 invalid_request_url
GET /v1/%75sers/me 400 invalid_request_url
GET /v1/user%73/me 400 invalid_request_url
GET /v1/users/%6De 400 validation_error
GET /v1/users/m%65 400 validation_error
GET /v1/users/me%2F 400 validation_error
The invalid_request_url body is {"object":"error","status":400,"code":"invalid_request_url","message":"Invalid request URL.","request_id":"<uuid>"}, the one an unrouted path gets. The validation_error for /v1/users/m%65 has this message, so the escape is decoded in the user id and the me it spells is not taken as the bot:
path failed validation: path.user_id should be a valid uuid, instead was `"me"`.
Reproduction — the bundled corpus, over backlot.serve(); http.client sends the path as written:
import http.client
import backlot
with backlot.serve() as s:
host = s.base_url.split("://")[1].rstrip("/")
for path in ("/v1/user%73/me", "/v1%2Fusers/me", "/v1/users/m%65", "/v1/users/me%2F"):
c = http.client.HTTPConnection(host)
c.putrequest("GET", "/notion" + path)
c.putheader("Authorization", f"Bearer {s.token}")
c.putheader("Notion-Version", "2025-09-03")
c.endheaders()
r = c.getresponse()
print(path, r.status, r.read()[:40])prints the bot user for each of them:
/v1/user%73/me 200 b'{"object":"user","id":"92087a6f-840f-44b'
/v1%2Fusers/me 200 b'{"object":"user","id":"92087a6f-840f-44b'
/v1/users/m%65 200 b'{"object":"user","id":"92087a6f-840f-44b'
/v1/users/me%2F 200 b'{"object":"user","id":"92087a6f-840f-44b'
| # | Divergence | Silent? |
|---|---|---|
| 1 | An escape in a segment the route spells out is routed as what it decodes to | yes |
| 2 | An escaped me is the bot user |
yes |
1. An escape in a segment the route spells out is routed as what it decodes to
Request — /v1/users/me with a / sent as %2F, or a letter or digit of v1 or users sent as its escape.
Backlot serves — the route the decoded path names: 200 and the bot user.
The real API serves — 400 invalid_request_url, the answer to a path no route has.
Can a client tell? Only a client that escapes those characters, which gets a user here where real refuses the URL.
2. An escaped me is the bot user
Request — /v1/users/%6De, /v1/users/m%65 or /v1/users/me%2F.
Backlot serves — 200 and the bot user, as for /v1/users/me.
The real API serves — 400 validation_error naming the decoded id, "me" for the first two.
Can a client tell? By the status, on a path a client builds from a user id it escapes.
What a fix has to settle — Starlette matches routes on scope["path"], the decoded path, where real's router matched each segment a route spells out as sent and decoded a path parameter before validating it. An id that is no UUID once decoded, mE or me (/v1/users/m%45, /v1/users/me%20), is answered 404 here where real answers 400, which is #376.
Activity