Skip to content

Commit 023b48b

Browse files
authored
Normalize request paths before route matching (#17772)
* Use normalized paths for runtime route selection * Normalize paths before route matching
1 parent 22e439b commit 023b48b

5 files changed

Lines changed: 80 additions & 33 deletions

File tree

.changeset/eight-actors-marry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes route selection for normalized request paths in adapter and development request handling

packages/astro/src/core/app/base.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { handleRequest } from '../routing/handler.js';
2323
import { getDefaultStatusCode } from '../routing/helpers.js';
2424
import { matchRequest } from '../routing/match-request.js';
2525
import { getRouteTable, matchRoute, updateRouteTable } from '../routing/route-table.js';
26+
import { validateAndDecodePathname } from '../util/pathname.js';
2627
import { setRenderOptions } from './render-options.js';
2728
import type { WaitUntilHook } from '../wait-until.js';
2829
import type { SSRManifest } from './types.js';
@@ -285,22 +286,22 @@ export abstract class BaseApp {
285286
}
286287

287288
/**
288-
* Decodes a pathname with `decodeURI`, falling back to the raw pathname when it
289-
* contains an invalid percent-sequence (e.g. `%C0%AF`, an overlong-UTF-8 encoding of
290-
* `/` commonly sent by path-traversal scanners). A raw `decodeURI()` would throw
291-
* `URIError: URI malformed`, and because `match()` runs before `render()` that error
292-
* escapes the adapter's request handler as an uncaught exception (HTTP 500) that user
293-
* middleware can't catch.
289+
* Fully decodes a pathname, falling back to a single decode and then the raw pathname
290+
* when validation fails. Adapter matching runs before `render()`, so it must not throw
291+
* for request input that render-time validation handles.
294292
*/
295-
private safeDecodeURI(pathname: string): string {
293+
private safeDecodePathname(pathname: string): string {
296294
try {
297-
return decodeURI(pathname);
295+
return validateAndDecodePathname(pathname);
298296
} catch (e: any) {
299-
// Malformed request paths are expected client input (commonly from automated
300-
// scanners) rather than a server fault, and this runs per-request on the hot
301-
// path. Log at `debug` so it stays diagnosable without flooding error logs.
297+
// Path decoding failures are request input rather than a server fault. Log at
298+
// `debug` so they stay diagnosable without flooding error logs.
302299
this.adapterLogger.debug(e.toString());
303-
return pathname;
300+
try {
301+
return decodeURI(pathname);
302+
} catch {
303+
return pathname;
304+
}
304305
}
305306
}
306307

@@ -311,7 +312,7 @@ export abstract class BaseApp {
311312
public getPathnameFromRequest(request: Request): string {
312313
const url = new URL(request.url);
313314
const pathname = prependForwardSlash(this.removeBase(url.pathname));
314-
return this.safeDecodeURI(pathname);
315+
return this.safeDecodePathname(pathname);
315316
}
316317

317318
/**
@@ -398,7 +399,7 @@ export abstract class BaseApp {
398399
if (!routeData) {
399400
const domainPathname = this.computePathnameFromDomain(request);
400401
if (domainPathname) {
401-
routeData = matchRoute(this.manifest, this.safeDecodeURI(domainPathname));
402+
routeData = matchRoute(this.manifest, this.safeDecodePathname(domainPathname));
402403
}
403404
}
404405
const resolvedOptions: ResolvedRenderOptions = {

packages/astro/src/core/routing/match-request.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,29 @@ import type { SSRManifest } from '../app/types.js';
44
import { computePathnameFromDomain } from '../i18n/domain.js';
55
import { AstroIntegrationLogger } from '../logger/core.js';
66
import { getLogger } from '../logger/manifest-logger.js';
7+
import { validateAndDecodePathname } from '../util/pathname.js';
78
import { matchAllRoutes, matchRoute } from './route-table.js';
89

910
/**
10-
* Decodes a pathname with `decodeURI`, falling back to the raw pathname when it
11-
* contains an invalid percent-sequence (e.g. `%C0%AF`, an overlong-UTF-8 encoding of
12-
* `/` commonly sent by path-traversal scanners). A raw `decodeURI()` would throw
13-
* `URIError: URI malformed`, and because `match()` runs before `render()` that error
14-
* escapes the adapter's request handler as an uncaught exception (HTTP 500) that user
15-
* middleware can't catch.
11+
* Fully decodes a pathname, falling back to a single decode and then the raw pathname
12+
* when validation fails. Matching runs before `render()`, so it must not throw for
13+
* request input that render-time validation handles.
1614
*/
17-
function safeDecodeURI(manifest: SSRManifest, pathname: string): string {
15+
function safeDecodePathname(manifest: SSRManifest, pathname: string): string {
1816
try {
19-
return decodeURI(pathname);
17+
return validateAndDecodePathname(pathname);
2018
} catch (e: any) {
21-
// Malformed request paths are expected client input (commonly from automated
22-
// scanners) rather than a server fault, and this runs per-request on the hot
23-
// path. Log at `debug` so it stays diagnosable without flooding error logs.
24-
// Allocated lazily — only on the malformed branch — with the same options
25-
// and label as the facade's `adapterLogger`.
19+
// Path decoding failures are request input rather than a server fault. Log at
20+
// `debug` so they stay diagnosable without flooding error logs. The logger is
21+
// allocated lazily with the same options and label as the facade's `adapterLogger`.
2622
new AstroIntegrationLogger(getLogger(manifest).options, manifest.adapterName).debug(
2723
e.toString(),
2824
);
29-
return pathname;
25+
try {
26+
return decodeURI(pathname);
27+
} catch {
28+
return pathname;
29+
}
3030
}
3131
}
3232

@@ -56,7 +56,8 @@ export function matchRequest(
5656
if (!pathname) {
5757
pathname = prependForwardSlash(stripRequestBase(url.pathname, manifest.base));
5858
}
59-
const routeData = matchRoute(manifest, safeDecodeURI(manifest, pathname));
59+
pathname = safeDecodePathname(manifest, pathname);
60+
const routeData = matchRoute(manifest, pathname);
6061
if (!routeData) return undefined;
6162
if (allowPrerenderedRoutes) {
6263
return routeData;
@@ -68,7 +69,7 @@ export function matchRequest(
6869
// the same pattern should handle all other URLs.
6970
if (routeData.prerender) {
7071
if (routeData.params.length > 0) {
71-
const allMatches = matchAllRoutes(manifest, safeDecodeURI(manifest, pathname));
72+
const allMatches = matchAllRoutes(manifest, pathname);
7273
return allMatches.find((r) => !r.prerender);
7374
}
7475
return undefined;

packages/astro/src/vite-plugin-app/handle-request.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createSafeError } from '../core/errors/index.js';
88
import { setLogger } from '../core/logger/manifest-logger.js';
99
import type { ModuleLoader } from '../core/module-loader/index.js';
1010
import { createRequest } from '../core/request.js';
11+
import { validateAndDecodePathname } from '../core/util/pathname.js';
1112
import { SERIALIZED_MANIFEST_ID } from '../manifest/serialized.js';
1213
import type { AstroSettings } from '../types/astro.js';
1314
import type { SSRManifest } from '../types/public/index.js';
@@ -114,8 +115,12 @@ export async function handleDevRequest(
114115
pathname = '';
115116
} else {
116117
// We already have a middleware that checks if there's an incoming URL that has invalid URI, so it's safe
117-
// to not handle the error: packages/astro/src/vite-plugin-astro-server/base.ts
118-
pathname = decodeURI(url.pathname);
118+
// to only handle paths that exceed the supported decoding depth here.
119+
try {
120+
pathname = validateAndDecodePathname(url.pathname);
121+
} catch {
122+
pathname = decodeURI(url.pathname);
123+
}
119124
}
120125

121126
// Add config.base back to url before passing it to SSR

packages/astro/test/units/app/double-encoding-bypass.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ const apiCatchAllRouteData = parseRoute('api/[...path].ts', routeOptions, {
2626
type: 'endpoint',
2727
});
2828

29+
const adminRouteData = parseRoute('api/admin/users.ts', routeOptions, {
30+
component: 'src/pages/api/admin/users.ts',
31+
type: 'endpoint',
32+
});
33+
2934
const publicRouteData = parseRoute('index.astro', routeOptions, {
3035
component: 'src/pages/index.astro',
3136
});
@@ -50,6 +55,14 @@ const pageMap = new Map<string, any>([
5055
}),
5156
}),
5257
],
58+
[
59+
adminRouteData.component,
60+
async () => ({
61+
page: async () => ({
62+
GET: async () => new Response('admin'),
63+
}),
64+
}),
65+
],
5366
[
5467
publicRouteData.component,
5568
async () => ({
@@ -98,17 +111,39 @@ function createRewriteAuthMiddleware() {
98111
})) as () => Promise<{ onRequest: MiddlewareHandler }>;
99112
}
100113

114+
function createPassthroughMiddleware() {
115+
return (async () => ({
116+
onRequest: (async (_context, next) => next()) satisfies MiddlewareHandler,
117+
})) as () => Promise<{ onRequest: MiddlewareHandler }>;
118+
}
119+
101120
function createApp(middleware: ReturnType<typeof createAuthMiddleware>) {
102121
return new App(
103122
createManifest({
104-
routes: [createRouteInfo(apiCatchAllRouteData), createRouteInfo(publicRouteData)],
123+
routes: [
124+
createRouteInfo(adminRouteData),
125+
createRouteInfo(apiCatchAllRouteData),
126+
createRouteInfo(publicRouteData),
127+
],
105128
pageMap: pageMap as any,
106129
middleware: middleware as any,
107130
}) as any,
108131
);
109132
}
110133

111134
describe('URL normalization: double-encoding middleware bypass', () => {
135+
it('normalizes the request pathname before adapter route matching', async () => {
136+
const app = createApp(createPassthroughMiddleware());
137+
const request = new Request('http://example.com/api/%2561dmin/users');
138+
const routeData = app.match(request);
139+
140+
assert.equal(app.getPathnameFromRequest(request), '/api/admin/users');
141+
assert.equal(routeData?.route, '/api/admin/users');
142+
const response = await app.render(request, { routeData });
143+
assert.equal(response.status, 200);
144+
assert.equal(await response.text(), 'admin');
145+
});
146+
112147
it('middleware blocks /api/admin/users', async () => {
113148
const app = createApp(createAuthMiddleware());
114149
const request = new Request('http://example.com/api/admin/users');

0 commit comments

Comments
 (0)