Skip to content

fix(event): don't crash the process on a malformed request URI#1406

Open
davidz627 wants to merge 1 commit into
h3js:mainfrom
davidz627:fix/decode-pathname-malformed-uri
Open

fix(event): don't crash the process on a malformed request URI#1406
davidz627 wants to merge 1 commit into
h3js:mainfrom
davidz627:fix/decode-pathname-malformed-uri

Conversation

@davidz627

@davidz627 davidz627 commented Jun 3, 2026

Copy link
Copy Markdown

🔗 Linked issue

Refs #1361 (and the previously-closed #1363).

📝 Description

decodePathname calls decodeURI, which throws a URIError: "URI malformed" on invalid percent-encoding — a bare %, or an invalid UTF-8 byte sequence such as %C0:

export function decodePathname(pathname: string): string {
  return decodeURI(
    pathname.includes("%25") ? pathname.replace(/%25/g, "%2525") : pathname,
  );
}

It's called from the H3Event constructor:

if (url.pathname.includes("%")) {
  url.pathname = decodePathname(url.pathname);
}

The new H3Event(...) call in H3Core["~request"] runs before the per-request try/catch, so the throw can't be caught by onError, Nitro, or consumer middleware. With the srvx Node adapter, serve() invokes the app's fetch directly inside the HTTP request listener, and h3's fetch throws synchronously (the event is constructed outside the try) — so it escapes as an uncaughtException and crashes the process. Any scanner/bot hitting a junk URL (GET /%) takes down a pod.

import { H3 } from "h3";

const app = new H3();
app.get("/**", () => "ok");

// Throws URIError: URI malformed (uncaught in a real server -> process crash)
await app.fetch(new Request("http://localhost/%"));

🔄 Changes

Guard the decode and fall back to the raw, undecoded pathname when decodeURI throws. Routing then simply won't match the un-decodable path (404) instead of crashing. Valid encoded paths still decode normally, and the existing %25-preservation behaviour is unchanged.

export function decodePathname(pathname: string): string {
  try {
    return decodeURI(
      pathname.includes("%25") ? pathname.replace(/%25/g, "%2525") : pathname,
    );
  } catch {
    // Fall back to the raw, undecoded pathname; routing then won't match it.
    return pathname;
  }
}

This is the same guard the issue reporter proposed in #1361. It was previously included in #1363, which was closed in favour of landing the path-traversal hardening (withoutBase / resolveDotSegments) separately — those landed on main, but this crash-guard did not. This PR is the minimal, guard-only remainder.

✅ Tests

  • test/unit/path.test.tsdecodePathname decodes valid input, preserves %25 (no double-decode), and returns malformed input unchanged without throwing.
  • test/security.test.ts — matrix (web + node) regression asserting /%, /%C0%AF, /foo%, /%E0%A4%A route normally instead of crashing.

Both fail against the current code (the node-mode cases time out — the crash signature) and pass with the fix. pnpm lint is clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved robustness of URL path handling to prevent application crashes when encountering malformed or invalid percent-encoded paths. Requests with invalid encoding now return appropriate HTTP responses instead of triggering unhandled exceptions.
  • Tests

    • Added security and unit test coverage for malformed URI handling and path decoding scenarios to ensure edge cases are properly managed.

decodePathname calls decodeURI, which throws a URIError on invalid
percent-encoding (a bare "%", or an invalid UTF-8 byte sequence such as
"%C0"). This runs in the H3Event constructor, before the per-request
try/catch in H3Core["~request"], so the throw escapes onError/middleware
and surfaces as an uncaughtException — any scanner hitting a junk URL
(GET /%) crashes the server.

Guard the decode and fall back to the raw, undecoded pathname when
decodeURI throws; routing then simply won't match it (404) instead of
crashing. Valid encoded paths still decode normally, and the existing
%25-preservation behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@davidz627 davidz627 requested a review from pi0 as a code owner June 3, 2026 22:28
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds defensive error handling to the decodePathname utility function to prevent malformed percent-encoded pathnames from throwing URIError and crashing the request pipeline. The fix wraps the decodeURI call in a try/catch block that returns the original (undecoded) pathname on failure, with comprehensive unit and integration test coverage.

Changes

URI Decoding Safety

Layer / File(s) Summary
Guarded pathname decoding with comprehensive test coverage
src/utils/internal/path.ts, test/unit/path.test.ts, test/security.test.ts
Implementation wraps decodeURI in try/catch, returning the original pathname on URIError to prevent crashes. Unit tests verify correct decoding of valid UTF-8, preservation of already-encoded %25 sequences to avoid double-decoding, and graceful handling of malformed percent-encoding without throwing. Integration tests confirm malformed request URIs return HTTP 200 without crashing the application.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes


Poem

🐰 A malformed percent broke the path so true,
But now we catch errors—both old and new!
Try/catch and testing in perfect harmony,
Safe routing flows like a rabbit runs free. 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and accurately summarizes the main change: preventing crashes when malformed request URIs are decoded.
Linked Issues check ✅ Passed The code changes fully satisfy all objectives from linked issue #1363: wrapping decodeURI in try/catch to handle URIError, returning original pathname on failure, maintaining %25 preservation logic, and comprehensive test coverage for both valid and malformed inputs.
Out of Scope Changes check ✅ Passed All changes are directly scoped to address the malformed URI handling requirement: decodePathname wrapper logic, unit tests validating behavior, and security regression tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/unit/path.test.ts (1)

4-22: ⚡ Quick win

Use describeMatrix for this test file.

This suite currently runs only as a plain Vitest unit suite, so it misses the repository’s required web/node matrix coverage for test/**/*.test.ts.

♻️ Minimal change
-import { describe, it, expect } from "vitest";
+import { describeMatrix } from "../_setup.ts";
 import { decodePathname } from "../../src/utils/internal/path.ts";
 
-describe("decodePathname", () => {
+describeMatrix("decodePathname", (_ctx, { it, expect }) => {
   it("decodes valid percent-encoding", () => {
     expect(decodePathname("/caf%C3%A9")).toBe("/café");
   });
@@
-});
+});

As per coding guidelines, "Use describeMatrix for writing cross-runtime tests that execute in both web and node modes".

🤖 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/unit/path.test.ts` around lines 4 - 22, Replace the plain Vitest suite
with a cross-runtime suite by swapping describe("decodePathname", ...) for
describeMatrix(...) so the tests run in both web and node; update the top-level
test declaration to use describeMatrix with a runtimes option (e.g., { runtimes:
["node", "web"] }) and keep the existing it(...) blocks and assertions
unchanged, targeting the decodePathname function in this file.
🤖 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.

Inline comments:
In `@test/security.test.ts`:
- Around line 103-107: The test currently only checks for a 200 status when
fetching malformed paths; update the spec in the loop that calls ctx.fetch(...)
to also assert that the response body (which your handler returns via
event.url.pathname) exactly equals the original raw request path variable (path)
for each case; locate the test using ctx.fetch and the loop over ["/%",
"/%C0%AF", "/foo%", "/%E0%A4%A"] and add an expectation comparing the response
text or returned pathname to the original path to ensure it isn’t rewritten.

---

Nitpick comments:
In `@test/unit/path.test.ts`:
- Around line 4-22: Replace the plain Vitest suite with a cross-runtime suite by
swapping describe("decodePathname", ...) for describeMatrix(...) so the tests
run in both web and node; update the top-level test declaration to use
describeMatrix with a runtimes option (e.g., { runtimes: ["node", "web"] }) and
keep the existing it(...) blocks and assertions unchanged, targeting the
decodePathname function in this file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 60c50d7f-8445-4e1a-b3ac-0d47a651d9d8

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb018e and 77cf33e.

📒 Files selected for processing (3)
  • src/utils/internal/path.ts
  • test/security.test.ts
  • test/unit/path.test.ts

Comment thread test/security.test.ts
Comment on lines +103 to +107
for (const path of ["/%", "/%C0%AF", "/foo%", "/%E0%A4%A"]) {
it(`handles ${path} without throwing`, async () => {
const res = await ctx.fetch(path);
expect(res.status).toBe(200);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert the raw pathname too, not just the 200.

Right now this regression passes even if malformed input gets rewritten to some other safe path. Since Line 96 already returns event.url.pathname, assert that it stays equal to the original malformed request path.

💡 Suggested assertion
   for (const path of ["/%", "/%C0%AF", "/foo%", "/%E0%A4%A"]) {
     it(`handles ${path} without throwing`, async () => {
       const res = await ctx.fetch(path);
       expect(res.status).toBe(200);
+      expect(await res.json()).toEqual({ path });
     });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const path of ["/%", "/%C0%AF", "/foo%", "/%E0%A4%A"]) {
it(`handles ${path} without throwing`, async () => {
const res = await ctx.fetch(path);
expect(res.status).toBe(200);
});
for (const path of ["/%", "/%C0%AF", "/foo%", "/%E0%A4%A"]) {
it(`handles ${path} without throwing`, async () => {
const res = await ctx.fetch(path);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ path });
});
🤖 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 103 - 107, The test currently only checks
for a 200 status when fetching malformed paths; update the spec in the loop that
calls ctx.fetch(...) to also assert that the response body (which your handler
returns via event.url.pathname) exactly equals the original raw request path
variable (path) for each case; locate the test using ctx.fetch and the loop over
["/%", "/%C0%AF", "/foo%", "/%E0%A4%A"] and add an expectation comparing the
response text or returned pathname to the original path to ensure it isn’t
rewritten.

@davidz627

Copy link
Copy Markdown
Author

hi @pi0! Hope you're doing well! What do you think of this fix? It's something that has trigged in production for my system a few times so I've already monkey-patched but would love to contribute the fix upstream

// the H3Event constructor, before the per-request try/catch, so an
// unguarded throw escapes as an uncaughtException and crashes the process.
// Fall back to the raw, undecoded pathname; routing then won't match it.
return pathname;

@pi0 pi0 Jun 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It is unsafe to ignore parse errors. (routing mismatches can cause some middleware like auth to be bypassed in certain conditions for example

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