Skip to content

App.match() uses unguarded decodeURI — malformed request path throws uncaught URIError (500) in on-demand adapters #16916

@timveil

Description

@timveil

Astro Info

Astro                    v6.4.2
Node                     v24.15.0
System                   macOS (arm64) / Linux (Cloudflare Workers / workerd)
Package Manager          pnpm
Output                   static (with on-demand `prerender = false` routes)
Adapter                  @astrojs/cloudflare (v13.6.0)

If this issue only occurs in one browser, which browser is a problem?

No response

Describe the Bug

App.match() (core/app/base.js) decodes the request pathname with a raw, unguarded decodeURI() in three places:

// core/app/base.js
const routeData = this.pipeline.matchRoute(decodeURI(pathname));      // ~L148
const allMatches = this.pipeline.matchAllRoutes(decodeURI(pathname)); // ~L155
routeData = this.pipeline.matchRoute(decodeURI(domainPathname));      // ~L262

If the request path contains a percent-sequence that is not valid UTF-8 — e.g. %C0%AF (an overlong-UTF-8 encoding of /, extremely common in automated path-traversal / .env scanners) — decodeURI() throws URIError: URI malformed.

On-demand adapters call App.match() directly per request. For example @astrojs/cloudflare's server handler:

// @astrojs/cloudflare/dist/utils/handler.js
routeData = app.match(request);   // not wrapped — URIError escapes the worker `fetch`

Because the throw happens before app.render(), it cannot be caught by user middleware (src/middleware.ts) either. The result is an uncaught exception → HTTP 500 (and, on Cloudflare, a logged worker exception) for any malformed request path, rather than a normal 404.

This is notable because Astro already handles this exact hazard gracefully everywhere else:

  • getPathnameFromRequest() in the same file wraps decodeURI in try/catch and falls back to the raw pathname:
    // core/app/base.js — getPathnameFromRequest()
    try {
      return decodeURI(pathname);
    } catch (e) {
      this.adapterLogger.error(e.toString());
      return pathname;
    }
  • normalizeUrl() (core/util/normalized-url.js) routes decoding through validateAndDecodePathname() (added in v6.3.2, Reject double-encoded URL paths in pathname normalization #16556) and falls back without throwing on a plain decode failure.

So App.match() is the one decode path that still uses an unguarded decodeURI, bypassing both the try/catch pattern and validateAndDecodePathname.

What's the expected result?

A malformed/undecodable request path should resolve to a normal 404 (no matching route), consistent with the graceful fallback already used by getPathnameFromRequest() and normalizeUrl() — not an uncaught URIError/500.

What's the actual result?

decodeURI() throws URIError: URI malformed; the exception escapes the adapter's fetch handler. On @astrojs/cloudflare this surfaces as a worker exception and a 500. User middleware cannot intercept it because it is thrown before the render pipeline runs.

Link to Minimal Reproducible Example

Any deployment with an on-demand (prerender = false) route reproduces it. Against such a deployment:

curl -i 'https://<your-site>/..%C0%AF.env'
# → 500 + "URIError: URI malformed" in adapter/worker logs

(Observed in production from automated .env scanners against an @astrojs/cloudflare Worker.)

Participation

  • I am willing to submit a pull request for this issue.

Suggested fix

Wrap the decodeURI calls in App.match() the same way getPathnameFromRequest() already does (or route them through validateAndDecodePathname with a graceful fallback), so a decode failure yields "no match" → 404 rather than an uncaught throw. Happy to open a PR mirroring the existing try/catch pattern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    - P3: minor bugAn edge case that only affects very specific usage (priority)fix pending verificationReporter needs to verify the triage bot fix workspkg: astroRelated to the core `astro` package (scope)

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions