Skip to content

Commit 3f8b5bc

Browse files
committed
docs: explain event.url.pathname decoding
1 parent 74d53c5 commit 3f8b5bc

2 files changed

Lines changed: 51 additions & 0 deletions

File tree

docs/1.guide/900.api/2.h3event.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,37 @@ app.get("/", async (event) => {
101101

102102
Access to the full parsed request [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL).
103103

104+
```ts
105+
app.get("/", (event) => {
106+
const { pathname, search, searchParams } = event.url;
107+
108+
return "OK";
109+
});
110+
```
111+
112+
#### Pathname decoding
113+
114+
`event.url.pathname` is percent-decoded **once**, while `event.req.url` keeps the original encoding exactly as it arrived on the wire. H3 decodes eagerly so that route matching and any pathname-based middleware always compare the same normalized value — otherwise a request to `/%61dmin` would slip past an `/admin` guard and still reach the `/admin` route.
115+
116+
Decoding is a single [`decodeURI`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURI) pass, and the result is re-serialized by the URL parser. This means only unreserved escapes visibly decode:
117+
118+
| Request | `event.url.pathname` | Why |
119+
| --------------- | -------------------- | ----------------------------------------- |
120+
| `/%41` | `/A` | Unreserved escape, decodes |
121+
| `/a%2eb` | `/a.b` | Unreserved escape, decodes |
122+
| `/a/%2e%2e/b` | `/b` | Decoded `..` collapses during re-parse |
123+
| `/x%2fy` | `/x%2fy` | Structural, kept encoded |
124+
| `/100%25` | `/100%25` | Structural, kept encoded |
125+
| `/a%20b` | `/a%20b` | Re-encoded by the URL serializer |
126+
| `/caf%C3%A9` | `/caf%C3%A9` | Re-encoded by the URL serializer |
127+
128+
Either way, a route param can never contain a path separator that the router did not match on: `%2f` stays encoded, and `%5c` decodes to `\`, which the URL parser then normalizes into a real `/` the router splits on (`/a%5cb` matches the route `/a/:id`).
129+
130+
> [!WARNING]
131+
> Never decode `event.url.pathname` again. A second `decodeURIComponent` can reintroduce a `/` or `..` that routing and middleware never saw, which is a path traversal vector when the value reaches a filesystem or an upstream URL. To read a route param in decoded form, use [`getRouterParams(event, { decode: true })`](/utils/request#getrouterparamsevent-opts-decode), which decodes everything else but keeps encoded separators encoded.
132+
133+
Requests with malformed percent-encoding (such as `/foo%` or `/%ZZ`) are rejected with a `400 Bad Request` before any handler runs. Set the [`allowMalformedURL`](/guide/api/h3#h3-options) app option to receive the raw pathname instead.
134+
104135
### `H3Event.res`
105136

106137
Prepared HTTP response status and headers.

src/event.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ export class H3Event<
4545
/**
4646
* Access to the parsed request URL.
4747
*
48+
* Unlike `event.req.url`, which keeps the original wire encoding, `event.url.pathname` is
49+
* percent-decoded **once**, so route matching and every pathname-based
50+
* middleware compare the same normalized value (`/%61dmin` cannot slip past an
51+
* `/admin` guard). Decoding is a single `decodeURI` pass and the result is
52+
* re-serialized by the URL parser, so:
53+
*
54+
* - Unreserved escapes decode: `/%41` → `/A`, `/a%2eb` → `/a.b` (and `%2e%2e`
55+
* segments then collapse, `/a/%2e%2e/b` → `/b`).
56+
* - Structural escapes stay encoded: `%2F`, `%3F`, `%23`, `%25`. A `:param`
57+
* can therefore never hold a raw `/` the router did not match on.
58+
* - Escapes the URL serializer re-adds stay encoded: `%20`, non-ASCII (`%C3%A9`).
59+
*
60+
* Never decode `pathname` a second time: it can reintroduce a `/` or `..` that
61+
* routing and middleware never saw (path traversal). To read a route param in
62+
* decoded form use `getRouterParams(event, { decode: true })`, which keeps
63+
* encoded separators encoded.
64+
*
65+
* Malformed encoding (`/foo%`, `/%ZZ`) is rejected with a 400 before any
66+
* handler runs, unless the `allowMalformedURL` app option is enabled.
67+
*
4868
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/URL)
4969
*/
5070
url: URL;

0 commit comments

Comments
 (0)