Skip to content

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

Open

Description

@nuriyoo

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

  1. added
    fidelityThe vendor API defines the right answer; closing this needs a measurement against it
    on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    fidelityThe vendor API defines the right answer; closing this needs a measurement against it

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions