diff --git a/.changeset/bold-stars-6d152.md b/.changeset/bold-stars-6d152.md new file mode 100644 index 000000000000..ab021fcbf1fe --- /dev/null +++ b/.changeset/bold-stars-6d152.md @@ -0,0 +1,9 @@ +--- +'astro': patch +--- + +Fixes double URL-encoded paths returning 400 Bad Request on on-demand routes + +Previously, any URL containing a double-encoded character (like `%255B`, which is `[` encoded twice) was unconditionally rejected with a `400 Bad Request` before middleware or route handlers could run. This broke embedded tools like Sanity Studio whose client-side router legitimately produces double-encoded URLs. + +The fix replaces the rejection approach with iterative decoding — multi-level percent-encoding is now fully resolved to its canonical form before being passed to middleware and route matching. This preserves the security fix for CVE-2025-66202 (middleware authorization bypass via double encoding) because middleware now always sees the fully decoded path, making bypass impossible. For example, `/api/%2561dmin` is decoded to `/api/admin`, which middleware can correctly block. diff --git a/.changeset/fix-compress-html-head-join.md b/.changeset/fix-compress-html-head-join.md new file mode 100644 index 000000000000..eea1f7cf8802 --- /dev/null +++ b/.changeset/fix-compress-html-head-join.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +fix(render): honour compressHTML when joining head elements diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts index 16386c249ae2..b9d534487718 100644 --- a/packages/astro/src/assets/build/generate.ts +++ b/packages/astro/src/assets/build/generate.ts @@ -1,13 +1,13 @@ import fs, { readFileSync } from 'node:fs'; import { basename } from 'node:path/posix'; import colors from 'piccolore'; -import { getOutDirWithinCwd } from '../../core/build/common.js'; import type { StaticBuildOptions } from '../../core/build/types.js'; import { getTimeStat } from '../../core/build/util.js'; import { AstroError } from '../../core/errors/errors.js'; import { AstroErrorData } from '../../core/errors/index.js'; import type { AstroLogger } from '../../core/logger/core.js'; import { isRemotePath, removeLeadingForwardSlash } from '../../core/path.js'; +import { getClientOutputDirectory } from '../../prerender/utils.js'; import type { MapValue } from '../../type-utils.js'; import type { AstroConfig } from '../../types/public/config.js'; import { getConfiguredImageService } from '../internal.js'; @@ -76,8 +76,11 @@ export async function prepareAssetsGenerationEnv( serverRoot = new URL('.prerender/', settings.config.build.server); clientRoot = settings.config.build.client; } else { - serverRoot = getOutDirWithinCwd(settings.config.outDir); - clientRoot = settings.config.outDir; + // For static builds, images have already been moved to the client output directory + // by ssrMoveAssets. Use getClientOutputDirectory to respect preserveBuildClientDir. + const clientOutputDir = getClientOutputDirectory(settings); + serverRoot = clientOutputDir; + clientRoot = clientOutputDir; } return { diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 421061e39ee5..dd2a17e7c814 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -20,7 +20,6 @@ import { appSymbol } from '../constants.js'; import { DefaultErrorHandler } from '../errors/default-handler.js'; import type { ErrorHandler } from '../errors/handler.js'; import { setRenderOptions } from './render-options.js'; -import { MultiLevelEncodingError } from '../util/pathname.js'; import type { WaitUntilHook } from '../wait-until.js'; import type { AppPipeline } from './pipeline.js'; import type { SSRManifest } from './types.js'; @@ -407,24 +406,15 @@ export abstract class BaseApp
{
};
let response: Response;
- try {
- if (this.#fetchHandler instanceof DefaultFetchHandler) {
- // Fast path: pass options directly, skip Reflect.set/get round-trip
- Reflect.set(request, appSymbol, this);
- response = await this.#fetchHandler.renderWithOptions(request, resolvedOptions);
- } else {
- // User-provided fetch handler: stamp options + app on the request
- setRenderOptions(request, resolvedOptions);
- Reflect.set(request, appSymbol, this);
- response = await this.#fetchHandler.fetch(request);
- }
- } catch (err: any) {
- // Multi-level encoding (e.g., %2561 → %61) is rejected during URL
- // normalization in FetchState. Return 400 without rendering an error page.
- if (err instanceof MultiLevelEncodingError) {
- return new Response('Bad Request', { status: 400 });
- }
- throw err;
+ if (this.#fetchHandler instanceof DefaultFetchHandler) {
+ // Fast path: pass options directly, skip Reflect.set/get round-trip
+ Reflect.set(request, appSymbol, this);
+ response = await this.#fetchHandler.renderWithOptions(request, resolvedOptions);
+ } else {
+ // User-provided fetch handler: stamp options + app on the request
+ setRenderOptions(request, resolvedOptions);
+ Reflect.set(request, appSymbol, this);
+ response = await this.#fetchHandler.fetch(request);
}
this.#warnMissingFeatures();
if (response.headers.get(ASTRO_ERROR_HEADER)) {
diff --git a/packages/astro/src/core/fetch/fetch-state.ts b/packages/astro/src/core/fetch/fetch-state.ts
index 935596a29e42..7112ba1dfc04 100644
--- a/packages/astro/src/core/fetch/fetch-state.ts
+++ b/packages/astro/src/core/fetch/fetch-state.ts
@@ -35,6 +35,7 @@ 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 { validateAndDecodePathname } from '../util/pathname.js';
import { getOriginPathname, setOriginPathname } from '../routing/rewrite.js';
import { computePathnameFromDomain } from '../i18n/domain.js';
import { getCustom404Route, routeHasHtmlExtension } from '../routing/helpers.js';
@@ -839,9 +840,9 @@ export class FetchState implements AstroFetchState {
return;
}
- // this.pathname is already decoded by #computePathname, so no
- // additional decodeURI here — that would double-decode and allow
- // double-encoded paths like /%2561dmin to bypass route checks.
+ // this.pathname is already fully decoded by #computePathname
+ // (which iteratively decodes all encoding levels), so no
+ // additional decoding is needed here.
const matched = pipeline.matchRoute(this.pathname);
// In production SSR, prerendered routes are served as static files
// by the hosting layer and should not be rendered by the app.
@@ -899,7 +900,7 @@ export class FetchState implements AstroFetchState {
}
pathname = prependForwardSlash(pathname);
try {
- return decodeURI(pathname);
+ return validateAndDecodePathname(pathname);
} catch (e: any) {
this.pipeline.logger.error(null, e.toString());
return pathname;
diff --git a/packages/astro/src/core/util/normalized-url.ts b/packages/astro/src/core/util/normalized-url.ts
index 455d7adb7490..88b30b993ef1 100644
--- a/packages/astro/src/core/util/normalized-url.ts
+++ b/packages/astro/src/core/util/normalized-url.ts
@@ -1,5 +1,5 @@
import { collapseDuplicateSlashes } from '@astrojs/internal-helpers/path';
-import { MultiLevelEncodingError, validateAndDecodePathname } from './pathname.js';
+import { validateAndDecodePathname } from './pathname.js';
/**
* Creates a normalized URL from a request URL string.
@@ -16,13 +16,8 @@ export function createNormalizedUrl(requestUrl: string): URL {
export function normalizeUrl(url: URL): URL {
try {
url.pathname = validateAndDecodePathname(url.pathname);
- } catch (e) {
- // Multi-level encoding (e.g., %2561 → %61) must be rejected, not silently decoded.
- // Let this error propagate so the caller can return a 400 response.
- if (e instanceof MultiLevelEncodingError) {
- throw e;
- }
- // For other decoding failures (truly malformed URLs), fall back gracefully.
+ } catch {
+ // For decoding failures (truly malformed URLs), fall back gracefully.
try {
url.pathname = decodeURI(url.pathname);
} catch {
diff --git a/packages/astro/src/core/util/pathname.ts b/packages/astro/src/core/util/pathname.ts
index fa760ea791c6..cf78d09973a7 100644
--- a/packages/astro/src/core/util/pathname.ts
+++ b/packages/astro/src/core/util/pathname.ts
@@ -2,6 +2,10 @@
* Error thrown when multi-level URL encoding is detected in a pathname.
* This is a distinct error type so callers can handle it specifically
* (e.g., returning a 400 response) rather than falling back to partial decoding.
+ *
+ * @deprecated No longer thrown internally — multi-level encoding is now
+ * decoded iteratively instead of rejected. Kept for backwards compatibility
+ * in case third-party code references the class.
*/
export class MultiLevelEncodingError extends Error {
constructor() {
@@ -10,37 +14,43 @@ export class MultiLevelEncodingError extends Error {
}
}
-const ENCODING_REGEX = /%25[0-9a-fA-F]{2}/;
-
/**
- * Validates that a pathname is not multi-level encoded.
- * Detects if a pathname contains encoding that was encoded again (e.g., %2561dmin where %25 decodes to %).
- * This prevents double/triple encoding bypasses of security checks.
+ * Decodes a pathname iteratively until stable, collapsing all levels of
+ * percent-encoding into a single canonical form. This prevents
+ * double/triple encoding from bypassing middleware authorization checks
+ * (CVE-2025-66202) — instead of rejecting multi-level encoding, we
+ * fully resolve it so middleware always sees the true decoded path.
*
- * @param pathname - The pathname to validate
- * @returns The decoded pathname if valid
- * @throws MultiLevelEncodingError if multi-level encoding is detected
- * @throws Error if the pathname contains invalid URL encoding
+ * @param pathname - The pathname to decode
+ * @returns The fully decoded pathname
+ * @throws Error if the pathname contains invalid URL encoding that
+ * cannot be decoded at all (e.g., a bare `%` not followed by hex digits)
*/
export function validateAndDecodePathname(pathname: string): string {
- // %25 (encoded %) followed by two hex digits is the signature of double-encoding.
- // Example: %2561 is %25 + 61, which decodes to %61, then to 'a'.
- // This is ambiguous with a literal "%" followed by hex characters (e.g. a file
- // named "%AB"), but rejecting it is the secure default — the alternative allows
- // middleware auth bypasses.
- if (ENCODING_REGEX.test(pathname)) {
- throw new MultiLevelEncodingError();
- }
let decoded: string;
try {
decoded = decodeURI(pathname);
} catch (_e) {
throw new Error('Invalid URL encoding');
}
- // Defense-in-depth: catch creative encodings that reassemble
- // into %25HH after the first decode pass
- if (ENCODING_REGEX.test(decoded)) {
- throw new MultiLevelEncodingError();
+ // Iteratively decode until stable. Multi-level encoding (e.g.,
+ // %2561 → %61 → a) is resolved completely so that downstream code
+ // — especially middleware auth checks — always sees the canonical
+ // pathname regardless of how many encoding layers the client used.
+ // We cap iterations to prevent infinite loops on pathological input.
+ let iterations = 0;
+ while (decoded !== pathname && iterations < 10) {
+ pathname = decoded;
+ try {
+ decoded = decodeURI(pathname);
+ } catch {
+ // decodeURI can fail when a decoded literal '%' forms an
+ // invalid sequence with adjacent characters (e.g., '%?.pdf'
+ // after decoding %25%3F). This is fine — we've decoded as
+ // far as possible.
+ break;
+ }
+ iterations++;
}
return decoded;
}
diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts
index 239f6e07e576..5aebc7b924d5 100644
--- a/packages/astro/src/runtime/server/render/head.ts
+++ b/packages/astro/src/runtime/server/render/head.ts
@@ -68,7 +68,8 @@ export function renderAllHeadContent(result: SSRResult) {
// consist of CSS modules which should naturally take precedence over CSS styles, so the
// order will still work. In prod, all CSS are stylesheet links.
// In the future, it may be better to have only an array of head elements to avoid these assumptions.
- content += styles.join('\n') + links.join('\n') + scripts.join('\n');
+ const sep = result.compressHTML === true || result.compressHTML === 'jsx' ? '' : '\n';
+ content += styles.join(sep) + links.join(sep) + scripts.join(sep);
content += result._metadata.extraHead.join('');
diff --git a/packages/astro/test/middleware.test.ts b/packages/astro/test/middleware.test.ts
index b007c6f8831a..53f1527a481a 100644
--- a/packages/astro/test/middleware.test.ts
+++ b/packages/astro/test/middleware.test.ts
@@ -120,16 +120,27 @@ describe('Middleware API in PROD mode, SSR', () => {
});
describe('Path encoding in middleware', () => {
- it('should reject double-encoded paths with 400', async () => {
+ it('middleware protects double-encoded /admin path', async () => {
+ // %2561dmin is decoded iteratively: %2561 → %61 → a → admin
+ // Middleware sees /admin and redirects (no auth token).
const request = new Request('http://example.com/%2561dmin');
const response = await app.render(request);
- assert.equal(response.status, 400);
+ assert.equal(
+ response.status,
+ 302,
+ 'double-encoded /admin should trigger middleware redirect',
+ );
});
- it('should reject triple-encoded paths with 400', async () => {
+ it('middleware protects triple-encoded /admin path', async () => {
+ // %252561dmin → %2561dmin → %61dmin → admin
const request = new Request('http://example.com/%252561dmin');
const response = await app.render(request);
- assert.equal(response.status, 400);
+ assert.equal(
+ response.status,
+ 302,
+ 'triple-encoded /admin should trigger middleware redirect',
+ );
});
});
diff --git a/packages/astro/test/units/app/double-encoding-bypass.test.ts b/packages/astro/test/units/app/double-encoding-bypass.test.ts
index 5f7e0ea20a0e..30e3b333cad4 100644
--- a/packages/astro/test/units/app/double-encoding-bypass.test.ts
+++ b/packages/astro/test/units/app/double-encoding-bypass.test.ts
@@ -9,11 +9,9 @@ import { createManifest, createRouteInfo } from './test-helpers.ts';
/**
* Tests that double-URL-encoded paths do not bypass middleware authorization.
*
- * When a path like /api/%2561dmin/users is received, validateAndDecodePathname
- * detects the multi-level encoding and must reject the request rather than
- * silently falling back to a single decodeURI() that leaves middleware
- * seeing a half-decoded pathname (/api/%61dmin/users) that doesn't match
- * its authorization checks.
+ * Multi-level encoding is decoded iteratively so middleware always sees the
+ * canonical path. For example, /api/%2561dmin/users is decoded to
+ * /api/admin/users, which the auth middleware correctly blocks with 401.
*/
const routeOptions: Parameters