Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/normalize-pathname-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'astro': minor
---

Adds an advanced `normalizePathname` option to `FetchState` for customizing how the incoming request pathname is turned into the canonical pathname used for routing, params, and the user-facing `Astro.url` / `context.url`.

By default, Astro normalizes request pathnames with a security-hardened function that iteratively decodes until the path stops changing and then collapses duplicate slashes, so middleware and routing always agree on one canonical path. A side effect of the decode step is that a value like `%25` is decoded to a bare `%`, so `Astro.url.pathname` can differ from `new URL(Astro.request.url).pathname`.

Advanced users building custom fetch handlers can now override this per request. The returned value is used **verbatim** — Astro applies no further decoding or slash normalization on top of it, giving you full control over the canonical pathname:

```ts
import { FetchState } from 'astro/fetch';

// Keep percent-encoded sequences (e.g. %25) intact
const state = new FetchState(request, {
normalizePathname: (pathname) => pathname,
});
```

The built-in default is exported as `normalizePathname` so custom implementations can delegate to it (fully or partially):

```ts
import { normalizePathname } from 'astro/fetch';
```

This option only affects the **incoming request**. Rewrite targets (`Astro.rewrite()`, middleware `next(payload)`) are developer-supplied and are not run through your function.

**Note:** Replacing the default is potentially unsafe. Because the result is used verbatim, your function takes over the security-relevant canonicalization the default performs — iteratively decoding multi-encoded paths (e.g. `/%2561dmin`) and collapsing duplicate slashes (e.g. `//admin`) that could otherwise slip past middleware authorization checks. Only override it if you understand the routing and security implications. The default behavior is unchanged.
112 changes: 101 additions & 11 deletions packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ import {
import { getParams, getProps } from '../render/index.js';
import { Rewrites } from '../rewrites/handler.js';
import { isRoute404or500, isRouteServerIsland } from '../routing/match.js';
import { normalizeUrl } from '../util/normalized-url.js';
import { MultiLevelEncodingError, validateAndDecodePathname } from '../util/pathname.js';
import {
type NormalizePathname,
// Aliased locally so the class member `normalizePathname` and this built-in
// default aren't easy to confuse. Exported publicly as `normalizePathname`.
normalizePathname as defaultNormalizePathname,
MultiLevelEncodingError,
} from '../util/pathname.js';
import { getOriginPathname, setOriginPathname } from '../routing/rewrite.js';
import { computePathnameFromDomain } from '../i18n/domain.js';
import { getCustom404Route, routeHasHtmlExtension } from '../routing/helpers.js';
Expand All @@ -57,6 +62,39 @@ export interface ContextProvider<T> {
finalize?: (value: T) => Promise<void> | void;
}

/**
* Advanced options for constructing a {@link FetchState}. Part of the public
* `astro/fetch` API surface (re-exported from that entry point).
*/
export interface FetchStateOptions {
/**
* **Advanced API.** Overrides how the incoming request's pathname is
* normalized into the canonical pathname used for routing, param
* extraction, and building the user-facing `Astro.url` / `context.url`.
*
* Defaults to {@link defaultNormalizePathname}, Astro's security-hardened
* canonicalizer (iterative decode + duplicate-slash collapse). The return
* value is used **verbatim** — supplying your own function gives you full
* control over the canonical pathname, e.g. a no-op `(pathname) => pathname`
* keeps percent-encoded sequences such as `%25` intact so
* `Astro.url.pathname` matches `new URL(Astro.request.url).pathname`.
*
* This only affects the **incoming request**. Rewrite targets
* (`Astro.rewrite()`, middleware `next(payload)`) are developer-supplied
* and are not run through this function.
*
* @remarks Replacing the default is potentially unsafe: because the result
* is used verbatim, your function takes over the security-relevant
* canonicalization the default performs — iteratively decoding
* multi-encoded paths (e.g. `/%2561dmin`) and collapsing duplicate slashes
* (e.g. `//admin`). Getting this wrong can let requests slip past
* middleware authorization checks. Only override it if you understand the
* routing/security implications, and consider delegating to
* {@link defaultNormalizePathname} for the parts you don't need to change.
*/
normalizePathname?: NormalizePathname;
}

/**
* The public contract of {@link FetchState} exposed to user-land code
* (custom fetch handlers, Hono middleware, etc.).
Expand Down Expand Up @@ -143,6 +181,15 @@ export class FetchState implements AstroFetchState {
pathname: string;
/** Resolved render options (addCookieHeader, clientAddress, locals, etc.). */
readonly renderOptions: ResolvedRenderOptions;
/**
* Normalizes the incoming request pathname into the canonical pathname used
* for routing, params, and the user-facing URL. Its result is used
* verbatim (no extra decode/slash-collapse afterward). Defaults to
* {@link defaultNormalizePathname}; overridable via {@link FetchStateOptions}.
* Only applied to the incoming request — rewrite targets are not run
* through this hook.
*/
readonly normalizePathname: NormalizePathname;
/** When the request started, used to log duration. */
readonly timeStart: number;

Expand Down Expand Up @@ -247,9 +294,16 @@ export class FetchState implements AstroFetchState {
/** Memoized preferred locale list. */
#preferredLocaleList: APIContext['preferredLocaleList'];

constructor(pipeline: Pipeline, request: Request, options?: ResolvedRenderOptions) {
constructor(
pipeline: Pipeline,
request: Request,
options?: ResolvedRenderOptions,
fetchStateOptions?: FetchStateOptions,
) {
this.pipeline = pipeline;
this.request = request;
// Resolve the pathname normalizer before deriving pathname/url below.
this.normalizePathname = fetchStateOptions?.normalizePathname ?? defaultNormalizePathname;
// Accept options directly (fast path from BaseApp.render) or fall
// back to reading them from the request symbol (user fetch handlers).
options ??= getRenderOptions(request);
Expand Down Expand Up @@ -283,7 +337,7 @@ export class FetchState implements AstroFetchState {
if (domainPathname) {
this.#domainPathname = domainPathname;
try {
this.pathname = decodeURI(domainPathname);
this.pathname = this.normalizePathname(domainPathname);
} catch {
this.pathname = domainPathname;
}
Expand All @@ -293,7 +347,7 @@ export class FetchState implements AstroFetchState {
this.timeStart = performance.now();
this.clientAddress = options?.clientAddress;
this.locals = (options?.locals ?? {}) as App.Locals;
this.url = normalizeUrl(url);
this.url = this.#normalizeUrl(url);
this.cookies = new AstroCookies(request);

// Apply X-Forwarded-* headers only when the user has configured
Expand Down Expand Up @@ -905,12 +959,14 @@ export class FetchState implements AstroFetchState {

/**
* Strips the pipeline's base from the request URL, prepends a forward
* slash, and decodes the pathname. Falls back to the raw (not decoded)
* pathname if `decodeURI` throws.
* slash, and normalizes the pathname via {@link normalizePathname}. Falls
* back to the raw (not normalized) pathname if the normalizer throws.
*
* Mirrors `BaseApp.removeBase`, including the
* `collapseDuplicateLeadingSlashes` fix that prevents middleware
* authorization bypass when the URL starts with `//`.
* `collapseDuplicateLeadingSlashes` is applied to the raw URL pathname
* *before* base stripping so a URL that starts with `//` still has its
* base removed correctly (mirrors `BaseApp.removeBase`). Any further
* canonicalization — including collapsing the remaining duplicate slashes —
* is the responsibility of `normalizePathname` (the default does it).
*/
#computePathname(url: URL): string {
let pathname = collapseDuplicateLeadingSlashes(url.pathname);
Expand All @@ -921,11 +977,13 @@ export class FetchState implements AstroFetchState {
}
pathname = prependForwardSlash(pathname);
try {
return validateAndDecodePathname(pathname);
return this.normalizePathname(pathname);
} catch (e: any) {
// The path was encoded too many times to fully decode. Mark it so
// the handler can reject the request with a 400 before middleware
// or routing run, instead of working with a half-decoded path.
// (Only the default normalizer throws this; a custom one opts out
// of this protection by not throwing.)
if (e instanceof MultiLevelEncodingError) {
this.invalidEncoding = true;
return pathname;
Expand All @@ -935,6 +993,38 @@ export class FetchState implements AstroFetchState {
}
}

/**
* Normalizes a parsed URL in place using this state's
* {@link normalizePathname}. Returns the same URL object.
*
* `this.url` is normalized with the *same* function as `this.pathname`, and
* — this matters for security — handles a failure the *same way* as
* {@link #computePathname}: it leaves the pathname raw (not normalized)
* rather than falling back to a second, different decode.
*
* User middleware reads `context.url.pathname` (i.e. `this.url`) for
* authorization checks, while routing uses `this.pathname`. If a failure
* caused `this.url` to fall back to a different normalization than
* `this.pathname`, the two could disagree — letting an attacker who can
* force the normalizer to throw slip a path past a middleware check that a
* later route match would resolve differently. Failing identically keeps
* them consistent.
*/
#normalizeUrl(url: URL): URL {
try {
url.pathname = this.normalizePathname(url.pathname);
} catch (e: any) {
// Leave url.pathname as the raw, un-normalized value (no secondary
// normalization). Mark over-encoding so the request is rejected
// with a 400 before middleware/routing run, matching
// #computePathname.
if (e instanceof MultiLevelEncodingError) {
this.invalidEncoding = true;
}
}
return url;
}

/**
* Reads X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port
* from the request headers, validates them against the manifest's
Expand Down
9 changes: 5 additions & 4 deletions packages/astro/src/core/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { ActionHandler } from '../../actions/handler.js';
import type { BaseApp } from '../app/base.js';
import type { Pipeline } from '../base-pipeline.js';
import { FetchState as BaseFetchState } from './fetch-state.js';
import type { AstroFetchState } from './fetch-state.js';
export type { AstroFetchState };
import type { AstroFetchState, FetchStateOptions } from './fetch-state.js';
export type { AstroFetchState, FetchStateOptions };
export { type NormalizePathname, normalizePathname } from '../util/pathname.js';
import { CacheHandler } from '../cache/handler.js';
import { appSymbol } from '../constants.js';
import { I18n } from '../i18n/handler.js';
Expand All @@ -34,8 +35,8 @@ function getApp(request: Request): BaseApp<Pipeline> {
}

export class FetchState extends BaseFetchState {
constructor(request: Request) {
super(getApp(request).pipeline, request);
constructor(request: Request, options?: FetchStateOptions) {
super(getApp(request).pipeline, request, undefined, options);
}
}

Expand Down
12 changes: 10 additions & 2 deletions packages/astro/src/core/middleware/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ForbiddenRewrite } from '../errors/errors-data.js';
import { AstroError } from '../errors/index.js';
import { getParams, type Pipeline } from '../render/index.js';
import { setOriginPathname } from '../routing/rewrite.js';
import { createNormalizedUrl } from '../util/normalized-url.js';
import { defineMiddleware } from './defineMiddleware.js';

// From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js
Expand Down Expand Up @@ -50,7 +51,7 @@ export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler {
}
const oldPathname = handleContext.url.pathname;
const pipeline: Pipeline = Reflect.get(handleContext, pipelineSymbol);
const { routeData, pathname } = await pipeline.tryRewrite(
const { routeData, pathname, newUrl } = await pipeline.tryRewrite(
payload,
handleContext.request,
);
Expand All @@ -76,7 +77,14 @@ export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler {

carriedPayload = payload;
handleContext.request = newRequest;
handleContext.url = new URL(newRequest.url);
// Normalize the URL the same way `FetchState#applyRewrite`
// does (decode + collapse slashes) using the resolved
// rewrite target, so intermediate middleware in the chain
// see the exact `context.url` the rendered route will use.
// Previously this was `new URL(newRequest.url)`, which left
// the pathname percent-encoded and could diverge from the
// pathname used for routing/params.
handleContext.url = createNormalizedUrl(newUrl.href);
handleContext.params = getParams(routeData, pathname);
handleContext.routePattern = routeData.route;
setOriginPathname(
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/core/rewrites/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ interface TryRewriteResult {
*
* Called by both `Rewrites.execute()` (user-triggered `Astro.rewrite`)
* and `AstroMiddleware` (middleware `next(payload)`).
*
* Note: rewrite targets are developer-supplied, so the URL is normalized
* with the default normalizer (`createNormalizedUrl`), not
* `state.normalizePathname`. A custom `normalizePathname` is scoped to the
* untrusted incoming request only.
*/
export function applyRewriteToState(
state: FetchState,
Expand Down
54 changes: 54 additions & 0 deletions packages/astro/src/core/util/pathname.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { collapseDuplicateSlashes } from '@astrojs/internal-helpers/path';

/**
* Thrown when a URL path is encoded so many times that we give up decoding it
* (see {@link validateAndDecodePathname}). When this happens we reject the
Expand Down Expand Up @@ -71,3 +73,55 @@ export function validateAndDecodePathname(pathname: string): string {
}
return decoded;
}

/**
* A function that turns a base-stripped, leading-slash-prefixed request
* pathname into the final, canonical pathname Astro uses for routing, param
* extraction, and the user-facing `Astro.url` / `context.url`.
*
* The return value is used **verbatim** — Astro applies no further decoding or
* slash normalization on top of it. This means a custom implementation has
* full control over the canonical pathname, but also takes on responsibility
* for any canonicalization the default performs for security (see
* {@link normalizePathname}).
*
* The default is Astro's security-hardened {@link normalizePathname}. Advanced
* users can supply their own via `new FetchState(request, { normalizePathname })`
* — for example a no-op `(pathname) => pathname` to keep percent-encoded
* sequences like `%25` intact so `Astro.url.pathname` matches
* `new URL(Astro.request.url).pathname`.
*/
export type NormalizePathname = (pathname: string) => string;

/**
* The default {@link NormalizePathname} used by `FetchState`, and Astro's
* built-in, security-hardened pathname canonicalizer. It performs three steps,
* in this order:
*
* 1. Iteratively decodes the path until it stops changing so middleware and
* routing always agree on one canonical path, rejecting paths encoded too
* many times (see {@link validateAndDecodePathname}).
* 2. Rewrites backslashes to forward slashes. The WHATWG URL parser does this
* for http(s) URLs when a string is assigned to `url.pathname`, so doing it
* here at the string level keeps the plain-string `this.pathname` and the
* parsed `this.url.pathname` in agreement — and, crucially, lets step 3
* catch the slashes it introduces.
* 3. Collapses runs of duplicate slashes (`//` → `/`). This runs *after* the
* two steps above so it also catches slashes introduced by decoding an
* encoded backslash (`%5C` → `\` → `/`), preventing bypasses like `//admin`
* slipping past a `pathname.startsWith('/admin')` middleware check. Added as
* a security fix in astro#15757.
*
* The result is fully canonical, so callers can use it verbatim without any
* further decoding or slash normalization. Exported so custom implementations
* can delegate to it (fully or partially), and so security tests can assert
* against the exact default.
*/
export const normalizePathname: NormalizePathname = (pathname) => {
const decoded = validateAndDecodePathname(pathname);
// Match the WHATWG URL parser's backslash handling for http(s) URLs, then
// collapse so an encoded backslash (e.g. `%5Cadmin` → `\admin` → `/admin`)
// can't reintroduce a `//admin`-style middleware bypass.
const withForwardSlashes = decoded.replace(/\\/g, '/');
return collapseDuplicateSlashes(withForwardSlashes);
};
Loading
Loading