diff --git a/.changeset/polite-balloons-rhyme.md b/.changeset/polite-balloons-rhyme.md new file mode 100644 index 000000000000..319f9a7467ba --- /dev/null +++ b/.changeset/polite-balloons-rhyme.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Ensures that URLs with multiple leading slashes (e.g. `//admin`) are normalized to a single slash before reaching middleware, so that pathname checks like `context.url.pathname.startsWith('/admin')` work consistently regardless of the request URL format diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index bb263aa19407..bc4b299b7005 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -1,5 +1,6 @@ import { appendForwardSlash, + collapseDuplicateLeadingSlashes, collapseDuplicateTrailingSlashes, hasFileExtension, isInternalPath, @@ -187,6 +188,10 @@ export abstract class BaseApp
{
}
public removeBase(pathname: string) {
+ // Collapse multiple leading slashes to prevent middleware authorization bypass.
+ // Without this, `//admin` would be treated as starting with base `/` and sliced
+ // to `/admin` for routing, while middleware still sees `//admin` in the URL.
+ pathname = collapseDuplicateLeadingSlashes(pathname);
if (pathname.startsWith(this.manifest.base)) {
return pathname.slice(this.baseWithoutTrailingSlash.length + 1);
}
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index ca52a80bf4b9..aaa4071b86df 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -37,6 +37,7 @@ import { getParams, getProps, type Pipeline, Slots } from './render/index.js';
import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js';
import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js';
import { AstroSession } from './session/runtime.js';
+import { collapseDuplicateLeadingSlashes } from '@astrojs/internal-helpers/path';
import { validateAndDecodePathname } from './util/pathname.js';
/**
@@ -86,6 +87,10 @@ export class RenderContext {
static #createNormalizedUrl(requestUrl: string): URL {
const url = new URL(requestUrl);
+ // Collapse multiple leading slashes so middleware sees the canonical pathname.
+ // Without this, a request to `//admin` would preserve `//admin` in context.url.pathname,
+ // bypassing middleware checks like `pathname.startsWith('/admin')`.
+ url.pathname = collapseDuplicateLeadingSlashes(url.pathname);
try {
// Decode and validate pathname to prevent multi-level encoding bypass attacks
url.pathname = validateAndDecodePathname(url.pathname);
diff --git a/packages/astro/test/units/app/double-slash-bypass.test.js b/packages/astro/test/units/app/double-slash-bypass.test.js
new file mode 100644
index 000000000000..f5defd12b491
--- /dev/null
+++ b/packages/astro/test/units/app/double-slash-bypass.test.js
@@ -0,0 +1,162 @@
+// @ts-check
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { App } from '../../../dist/core/app/app.js';
+import { parseRoute } from '../../../dist/core/routing/parse-route.js';
+import { createComponent, render } from '../../../dist/runtime/server/index.js';
+import { createManifest } from './test-helpers.js';
+
+/**
+ * Security tests for double-slash URL prefix middleware authorization bypass.
+ *
+ * Vulnerability: A normalization inconsistency between route matching and middleware
+ * URL construction allows bypassing middleware-based authorization by prepending an
+ * extra `/` to the URL path (e.g., `//admin` instead of `/admin`).
+ *
+ * - `removeBase("//admin")` strips one slash → router matches `/admin`
+ * - `context.url.pathname` preserves `//admin` → middleware `startsWith("/admin")` fails
+ *
+ * See: withastro/astro-security#5
+ * CWE-647: Use of Non-Canonical URL Paths for Authorization Decisions
+ * CWE-285: Improper Authorization
+ */
+
+const routeOptions = /** @type {ParametersAdmin Panel
`;
+});
+
+const dashboardPage = createComponent(() => {
+ return render`Dashboard
`;
+});
+
+const publicPage = createComponent(() => {
+ return render`Public
`;
+});
+
+const pageMap = new Map([
+ [
+ adminRouteData.component,
+ async () => ({
+ page: async () => ({
+ default: adminPage,
+ }),
+ }),
+ ],
+ [
+ dashboardRouteData.component,
+ async () => ({
+ page: async () => ({
+ default: dashboardPage,
+ }),
+ }),
+ ],
+ [
+ publicRouteData.component,
+ async () => ({
+ page: async () => ({
+ default: publicPage,
+ }),
+ }),
+ ],
+]);
+
+/**
+ * Middleware that blocks access to /admin and /dashboard routes,
+ * as recommended in the official Astro authentication docs.
+ * @returns {() => Promise<{onRequest: import('../../../dist/types/public/common.js').MiddlewareHandler}>}
+ */
+function createAuthMiddleware() {
+ return async () => ({
+ onRequest: /** @type {import('../../../dist/types/public/common.js').MiddlewareHandler} */ (
+ async (context, next) => {
+ const protectedPaths = ['/admin', '/dashboard'];
+ if (protectedPaths.some((p) => context.url.pathname.startsWith(p))) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ return next();
+ }
+ ),
+ });
+}
+
+/**
+ * @param {ReturnType