Skip to content

fix(event): return 400 for malformed percent-encoded request URLs#1424

Merged
pi0 merged 3 commits into
mainfrom
fix/malformed-url-400
Jul 2, 2026
Merged

fix(event): return 400 for malformed percent-encoded request URLs#1424
pi0 merged 3 commits into
mainfrom
fix/malformed-url-400

Conversation

@pi0x

@pi0x pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem

The H3Event constructor normalizes the request pathname via decodePathname (which uses decodeURI) to keep route matching and middleware guards consistent. decodeURI throws a URIError on any malformed percent sequence — /foo%, /%ZZ, /bar%2, /%.

The event is constructed on h3.ts before the try/catch in ~request, so the error escaped h3's error handling entirely:

  • app.fetch() / app.request() rejected instead of returning a Response, violating the handler contract.
  • It bypassed onError / onResponse and h3's error formatting, surfacing as an unhandled 500 / connection crash depending on the runtime adapter.
  • Any unauthenticated client could trigger it with a one-character malformed path — a cheap robustness / DoS vector.

Native URL does not throw on these inputs, so this exposure was introduced by the percent-encoding normalization added for the middleware-bypass fix; it did not exist before.

Reproduced against the actual app:

GET /foo%   -> THREW URIError: URI malformed
GET /%ZZ    -> THREW URIError: URI malformed
GET /bar%2  -> THREW URIError: URI malformed

Fix

  • Catch the decode failure in the H3Event constructor, keep the raw pathname (route matching and middleware guards read the same single event.url.pathname, so there is no matcher disagreement / auth bypass), and flag the event.
  • In ~request, reject a flagged event with a clean 400 (HTTPError({ status: 400, message: "Bad Request" })) before any routing or app logic runs. Response body: {"status":400,"message":"Bad Request"}.
  • requestWithBaseURL (the mount path) now falls back to the raw pathname instead of throwing.

Opt out: allowMalformedURL

For apps that want the old lenient behavior (e.g. proxies that forward the raw path untouched), a new config flag disables the 400 and lets malformed URLs through with the raw, undecoded pathname:

const app = new H3({ allowMalformedURL: true });
app.get("/**", (event) => event.url.pathname); // "/foo%" reaches the handler as-is

Default is false (reject with 400). The middleware-bypass invariant still holds either way, since routing and guards read the same single event.url.pathname.

Why raw-fallback is not a bypass

The original middleware-bypass class came from route matching and guards seeing different path representations. Here both read the single event.url.pathname; on decode failure that value is simply the raw string, which both consumers still read identically — they cannot disagree. Verified: no malformed probe reaches the guarded admin handler, and the full existing security.test.ts suite passes.

Cross-runtime behavior comparison

To confirm 400 is the right default, I compared against nginx and the bare Bun/Deno runtimes. Probed with curl --path-as-is (raw path sent unchanged); table shows status · resulting-path:

Request path nginx 1.30 Bun (raw) Deno (raw) h3 (this PR)
/foo% (malformed) 400 200 · /foo% 200 · /foo% 400
/%ZZ (malformed) 400 200 · /%ZZ 200 · /%ZZ 400
/bar%2 (truncated) 400 200 · /bar%2 200 · /bar%2 400
/%2e%2e/x (encoded ..) 400 200 · /x 200 · /x 200 · /x
/%61dmin (encoded a) 200 · /admin 200 · /%61dmin 200 · /%61dmin 200 · /admin
/a%2fb (encoded /) 200 · /a/b 200 · /a%2fb 200 · /a%2fb 200 · /a%2fb

Takeaways:

  • On malformed percent-encoding, nginx returns a hard 400 and this PR now matches it by default. Before the patch h3 threw a URIError on these inputs — strictly worse than either nginx (clean 400) or Bun/Deno (lenient 200). allowMalformedURL: true reproduces the lenient Bun/Deno behavior for anyone who wants it.
  • Bare Bun and Deno are the lenient outliers — 200 for every malformed input, handing the handler the raw undecoded string. Their new URL(req.url) never throws (WHATWG parser tolerates stray %); Node's URL is the same. The throw only appeared in h3 because decodePathname deliberately uses the stricter decodeURI to normalize.
  • /%61dmin is the security-relevant divergence: nginx and h3 both normalize to /admin (an encoded path and its plain form are the same route → a guard can't be dodged), whereas bare Bun/Deno keep /%61dmin as a distinct string — the middleware-bypass footgun. h3 normalizes identically across runtimes: verified h3-on-Bun also yields /admin and 400s, so it does not inherit the underlying runtime's leniency.
  • Two intentional differences from nginx remain and are fine: h3 keeps %2f encoded (safer than nginx decoding it), and h3 routes a null byte (%00) where nginx 400s (could tighten later if desired, but out of scope).

Net: this PR removes the crash and lands h3 on nginx's proven 400 behavior for un-decodable URLs by default, while bare Bun/Deno would silently pass those requests through — and allowMalformedURL is available for apps that need the lenient path.

Tests

New matrix tests in test/security.test.ts (web + real Node server):

  • /foo%, /%ZZ, /bar%2, /%400, no throw.
  • /api/admin%ZZ/users does not reach the guarded handler.
  • allowMalformedURL: true → malformed URL passes through with the raw pathname (200); default → 400.

Bundle-size guard rails in test/bench/bundle.test.ts bumped modestly to account for the added code (H3Core gzip 2600→2650, defineHandler bytes 6100→6200).

Full suite: 1191 passed / 15 skipped. Lint + typecheck clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an option to allow malformed percent-encoded URLs to pass through with their raw pathname.
  • Bug Fixes
    • Requests with malformed URL paths now return 400 Bad Request instead of failing unexpectedly.
    • Improved routing safety so malformed paths don’t bypass access checks.
  • Documentation
    • Updated API docs to describe the new URL handling option.

@pi0x
pi0x requested a review from pi0 as a code owner July 2, 2026 17:54
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds malformed-percent URL detection, rejects those requests with HTTP 400 before routing unless opted in, falls back to raw pathnames in base URL resolution, and updates tests, docs, and bundle-size thresholds.

Changes

Malformed URL Detection and Rejection

Layer / File(s) Summary
Malformed URL marker and event decoding guard
src/event.ts
Adds exported kMalformedURL symbol and wraps pathname decoding in H3Event constructor with try/catch, setting the marker and preserving raw pathname on decode failure.
Early 400 rejection in request flow
src/h3.ts
Imports kMalformedURL and throws 400 in H3Core's internal ~request before onRequest/handler execution when the event is flagged malformed.
Base URL fallback and validation support
src/utils/request.ts, test/security.test.ts, docs/1.guide/900.api/1.h3.md, test/bench/bundle.test.ts
requestWithBaseURL falls back to the raw pathname on decode errors; security tests cover malformed URLs and allowMalformedURL; docs add the new option; bundle thresholds are raised.

Estimated code review effort: 2 (Simple) | ~12 minutes

Possibly related issues

  • h3js/h3#1407: Shares pathname normalization and encoded-URL handling in src/event.ts.

Possibly related PRs

  • h3js/h3#1342: Touches requestWithBaseURL-style pathname handling in the same area of request mounting and base URL logic.

Suggested reviewers: pi0

Poem

A bunny met a path askew,
With % signs that would not do.
Now malformed hops meet 400 light,
And raw paths scamper through just right. 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: malformed percent-encoded request URLs now return 400 instead of throwing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/malformed-url-400

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The H3Event constructor normalizes the pathname via decodePathname
(decodeURI), which throws a URIError on malformed percent-encoding such
as `/foo%`, `/%ZZ` or `/bar%2`. Because the event is constructed before
the try/catch in `~request`, the error escaped h3's error handling
entirely: `app.fetch()`/`app.request()` rejected instead of returning a
Response, bypassing onError/onResponse hooks and surfacing as an
unhandled 500/crash depending on the runtime adapter — a cheap
robustness/DoS vector for unauthenticated clients.

Catch the decode failure, keep the raw pathname (so route matching and
middleware guards still see one consistent value — no bypass), flag the
event, and reject it with a clean 400 before any routing or app logic
runs. `requestWithBaseURL` (mount path) now falls back to the raw
pathname instead of throwing as well.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x
pi0x force-pushed the fix/malformed-url-400 branch from aaf9ba0 to f0af5d6 Compare July 2, 2026 18:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/event.ts (1)

71-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard correctly prevents URIError from escaping the constructor.

Catching the decode failure and preserving the raw pathname keeps route matching and middleware guards consistent, addressing the DoS vector described in the PR. One minor note: this duplicates the same try/catch fallback pattern used in requestWithBaseURL (src/utils/request.ts). Consider extracting a shared helper (e.g. safeDecodePathname) to avoid maintaining two copies of this logic.

♻️ Example shared helper
// src/utils/internal/path.ts
export function tryDecodePathname(pathname: string): { pathname: string; malformed: boolean } {
  try {
    return { pathname: decodePathname(pathname), malformed: false };
  } catch {
    return { pathname, malformed: true };
  }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/event.ts` around lines 71 - 78, The pathname decode fallback is
duplicated between the constructor logic in Event and the equivalent handling in
requestWithBaseURL, so extract the shared try/catch behavior into a helper such
as safeDecodePathname or tryDecodePathname and reuse it from both places. Keep
the helper responsible for returning the decoded pathname plus the malformed
flag, then update the Event constructor and the request utility to call that
helper instead of maintaining separate copies of the decodePathname fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/event.ts`:
- Around line 71-78: The pathname decode fallback is duplicated between the
constructor logic in Event and the equivalent handling in requestWithBaseURL, so
extract the shared try/catch behavior into a helper such as safeDecodePathname
or tryDecodePathname and reuse it from both places. Keep the helper responsible
for returning the decoded pathname plus the malformed flag, then update the
Event constructor and the request utility to call that helper instead of
maintaining separate copies of the decodePathname fallback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5641ac98-2d12-44e7-970f-b16b70634b19

📥 Commits

Reviewing files that changed from the base of the PR and between 2a164f3 and aaf9ba0.

📒 Files selected for processing (5)
  • src/event.ts
  • src/h3.ts
  • src/utils/request.ts
  • test/bench/bundle.test.ts
  • test/security.test.ts

pi0 and others added 2 commits July 2, 2026 18:10
…med URLs

By default H3 rejects requests with a malformed percent-encoded URL path
(e.g. `/foo%`, `/%ZZ`) with a 400 before routing. Setting
`allowMalformedURL: true` lets such requests through with the raw,
undecoded pathname instead, leaving handling to the app.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0
pi0 merged commit babc6ff into main Jul 2, 2026
7 of 9 checks passed
@pi0
pi0 deleted the fix/malformed-url-400 branch July 2, 2026 18:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/security.test.ts (1)

1-3: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New tests bypass the established describeMatrix/ctx.fetch pattern.

Every other block in this file (and the repo's documented test convention) uses describeMatrix with ctx.app/ctx.fetch for a fresh, cross-runtime H3 instance per test. The new security: allowMalformedURL opt-in block instead uses plain describe/it from vitest and manually instantiates new H3()/app.request(...), skipping web/node matrix coverage and the ctx.errors unhandled-error assertions the other suites get for free.

♻️ Suggested refactor to align with existing test conventions
-describe("security: allowMalformedURL opt-in", () => {
-  it("rejects malformed URLs with 400 by default", async () => {
-    const app = new H3();
-    app.get("/**", () => "ok");
-    const res = await app.request("/foo%");
-    expect(res.status).toBe(400);
-  });
-
-  it("passes malformed URLs through with the raw pathname when enabled", async () => {
-    const app = new H3({ allowMalformedURL: true });
-    app.get("/**", (event) => event.url.pathname);
-    const res = await app.request("/foo%");
-    expect(res.status).toBe(200);
-    expect(await res.text()).toBe("/foo%");
-  });
-});
+describeMatrix("security: allowMalformedURL opt-in", (ctx, { it, expect }) => {
+  it("rejects malformed URLs with 400 by default", async () => {
+    ctx.app.get("/**", () => "ok");
+    const res = await ctx.fetch("/foo%");
+    expect(res.status).toBe(400);
+  });
+});
+
+describeMatrix(
+  "security: allowMalformedURL opt-in (enabled)",
+  (ctx, { it, expect }) => {
+    it("passes malformed URLs through with the raw pathname when enabled", async () => {
+      ctx.app.config.allowMalformedURL = true;
+      ctx.app.get("/**", (event) => event.url.pathname);
+      const res = await ctx.fetch("/foo%");
+      expect(res.status).toBe(200);
+      expect(await res.text()).toBe("/foo%");
+    });
+  },
+);

Note: ctx.app from _setup.ts may not currently support constructing with allowMalformedURL directly — check whether the config can be mutated post-construction or if _setup.ts needs a variant that accepts custom H3Config.

As per coding guidelines, "Use describeMatrix for writing cross-runtime tests that execute in both web and node modes" and "Ensure each test has a fresh H3 instance via beforeEach setup".

Also applies to: 125-140

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/security.test.ts` around lines 1 - 3, The new security tests are
bypassing the repo’s standard cross-runtime setup, so refactor the `security:
allowMalformedURL opt-in` block to use `describeMatrix` instead of plain
`describe`/`it`. Build the test around the existing `_setup.ts` pattern with
`ctx.app` and `ctx.fetch` (or extend `_setup.ts` to accept the needed
`H3Config`) so each case gets a fresh `H3` instance and the usual `ctx.errors`
assertions. Keep the `allowMalformedURL` coverage, but wire it through the
matrix harness rather than calling `new H3()` and `app.request(...)` directly.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/security.test.ts`:
- Around line 1-3: The new security tests are bypassing the repo’s standard
cross-runtime setup, so refactor the `security: allowMalformedURL opt-in` block
to use `describeMatrix` instead of plain `describe`/`it`. Build the test around
the existing `_setup.ts` pattern with `ctx.app` and `ctx.fetch` (or extend
`_setup.ts` to accept the needed `H3Config`) so each case gets a fresh `H3`
instance and the usual `ctx.errors` assertions. Keep the `allowMalformedURL`
coverage, but wire it through the matrix harness rather than calling `new H3()`
and `app.request(...)` directly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d093be3d-d8aa-4339-9ffa-b3246e13e769

📥 Commits

Reviewing files that changed from the base of the PR and between f0af5d6 and 7dc7187.

📒 Files selected for processing (4)
  • docs/1.guide/900.api/1.h3.md
  • src/h3.ts
  • src/types/h3.ts
  • test/security.test.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/1.guide/900.api/1.h3.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/h3.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants