VYPR
Moderate severityNVD Advisory· Published Nov 19, 2025· Updated Nov 20, 2025

Astro middleware authentication checks based on url.pathname can be bypassed via url encoded values

CVE-2025-64765

Description

Astro is a web framework. Prior to version 5.15.8, a mismatch exists between how Astro normalizes request paths for routing/rendering and how the application’s middleware reads the path for validation checks. Astro internally applies decodeURI() to determine which route to render, while the middleware uses context.url.pathname without applying the same normalization (decodeURI). This discrepancy may allow attackers to reach protected routes using encoded path variants that pass routing but bypass validation checks. This issue has been patched in version 5.15.8.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
astronpm
< 5.15.85.15.8

Affected products

1

Patches

1
6f800813516b

Fix middleware pathname matching by normalizing URL-encoded paths (#14771)

https://github.com/withastro/astroMatthew PhillipsNov 15, 2025via ghsa
7 files changed · +88 6
  • .changeset/middleware-path-normalization.md+7 0 added
    @@ -0,0 +1,7 @@
    +---
    +"astro": "patch"
    +---
    +
    +Fix middleware pathname matching by normalizing URL-encoded paths
    +
    +Middleware now receives normalized pathname values, ensuring that encoded paths like `/%61dmin` are properly decoded to `/admin` before middleware checks. This prevents potential security issues where middleware checks might be bypassed through URL encoding.
    
  • packages/astro/src/core/render-context.ts+12 3 modified
    @@ -61,7 +61,7 @@ export class RenderContext {
     		public clientAddress: string | undefined,
     		protected cookies = new AstroCookies(request),
     		public params = getParams(routeData, pathname),
    -		protected url = new URL(request.url),
    +		protected url = RenderContext.#createNormalizedUrl(request.url),
     		public props: Props = {},
     		public partial: undefined | boolean = undefined,
     		public shouldInjectCspMetaTags = !!pipeline.manifest.csp,
    @@ -70,6 +70,15 @@ export class RenderContext {
     			: undefined,
     	) {}
     
    +	static #createNormalizedUrl(requestUrl: string): URL {
    +		const url = new URL(requestUrl);
    +		try {
    +			url.pathname = decodeURI(url.pathname);
    +		} finally {
    +			return url;
    +		}
    +	}
    +
     	/**
     	 * A flag that tells the render content if the rewriting was triggered
     	 */
    @@ -217,7 +226,7 @@ export class RenderContext {
     					);
     				}
     				this.isRewriting = true;
    -				this.url = new URL(this.request.url);
    +				this.url = RenderContext.#createNormalizedUrl(this.request.url);
     				this.params = getParams(routeData, pathname);
     				this.pathname = pathname;
     				this.status = 200;
    @@ -362,7 +371,7 @@ export class RenderContext {
     				this.routeData.route,
     			);
     		}
    -		this.url = new URL(this.request.url);
    +		this.url = RenderContext.#createNormalizedUrl(this.request.url);
     		const newCookies = new AstroCookies(this.request);
     		if (this.cookies) {
     			newCookies.merge(this.cookies);
    
  • packages/astro/src/vite-plugin-astro-server/request.ts+1 1 modified
    @@ -41,7 +41,7 @@ export async function handleRequest({
     	}
     
     	// Add config.base back to url before passing it to SSR
    -	url.pathname = removeTrailingForwardSlash(config.base) + url.pathname;
    +	url.pathname = removeTrailingForwardSlash(config.base) + decodeURI(url.pathname);
     
     	// Apply trailing slash configuration consistently
     	if (config.trailingSlash === 'never') {
    
  • packages/astro/test/actions.test.js+2 2 modified
    @@ -117,7 +117,7 @@ describe('Astro Actions', () => {
     		});
     
     		it('Handles special characters in action names', async () => {
    -			for (const name of ['with%2Fslash', 'with%20space', 'with%2Edot']) {
    +			for (const name of ['with%2Fslash', 'with%20space']) {
     				const res = await fixture.fetch(`/_actions/${name}`, {
     					method: 'POST',
     					body: JSON.stringify({ name: 'ben' }),
    @@ -534,7 +534,7 @@ describe('Astro Actions', () => {
     		});
     
     		it('Handles special characters in action names', async () => {
    -			for (const name of ['with%2Fslash', 'with%20space', 'with%2Edot']) {
    +			for (const name of ['with%2Fslash', 'with%20space']) {
     				const req = new Request(`http://example.com/_actions/${name}`, {
     					method: 'POST',
     					body: JSON.stringify({ name: 'ben' }),
    
  • packages/astro/test/fixtures/middleware space/src/middleware.js+10 0 modified
    @@ -1,6 +1,16 @@
     import { defineMiddleware, sequence } from 'astro:middleware';
     
     const first = defineMiddleware(async (context, next) => {
    +	// Auth check: protect /admin route
    +	if (context.url.pathname === '/admin') {
    +		const authToken = context.request.headers.get('Authorization');
    +		if (!authToken) {
    +			return context.redirect('/');
    +		}
    +		// Auth token present, allow access
    +		return await next();
    +	}
    +
     	if (context.request.url.includes('/lorem')) {
     		context.locals.name = 'ipsum';
     	} else if (context.request.url.includes('/rewrite')) {
    
  • packages/astro/test/fixtures/middleware space/src/pages/admin.astro+6 0 added
    @@ -0,0 +1,6 @@
    +---
    +// Admin page - protected route
    +---
    +
    +<h1>Admin Panel</h1>
    +<p>Secret admin content</p>
    
  • packages/astro/test/middleware.test.js+50 0 modified
    @@ -94,6 +94,26 @@ describe('Middleware in DEV mode', () => {
     		assert.notEqual(headers.get('set-cookie'), null);
     	});
     
    +	describe('Path encoding in middleware', () => {
    +		it('should protect /admin route with auth check', async () => {
    +			const res = await fixture.fetch('/admin', { redirect: 'manual' });
    +			assert.equal(res.status, 302);
    +			assert.equal(res.headers.get('location'), '/');
    +		});
    +
    +		it('should NOT allow accessing /admin with url encoding', async () => {
    +			const res = await fixture.fetch('/%61dmin', { redirect: 'manual' });
    +			assert.equal(res.status, 302);
    +			assert.equal(res.headers.get('location'), '/');
    +		});
    +
    +		it('should NOT allow accessing /admin with fully encoded path', async () => {
    +			const res = await fixture.fetch('/%61%64%6d%69%6e', { redirect: 'manual' });
    +			assert.equal(res.status, 302);
    +			assert.equal(res.headers.get('location'), '/');
    +		});
    +	});
    +
     	describe('Integration hooks', () => {
     		it('Integration middleware marked as "pre" runs', async () => {
     			const res = await fixture.fetch('/integration-pre');
    @@ -335,6 +355,36 @@ describe('Middleware API in PROD mode, SSR', () => {
     		assert.equal(text.includes('<p>yes they can!</p>'), true);
     	});
     
    +	describe('Path encoding in middleware', () => {
    +		it('should allow accessing /admin with valid auth header', async () => {
    +			const request = new Request('http://example.com/admin', {
    +				headers: { Authorization: 'Bearer token123' },
    +			});
    +			const response = await app.render(request);
    +			assert.equal(response.status, 200);
    +			const html = await response.text();
    +			assert.equal(html.includes('Admin Panel'), true);
    +		});
    +
    +		it('should NOT allow accessing /admin without auth header', async () => {
    +			const request = new Request('http://example.com/admin');
    +			const response = await app.render(request);
    +			assert.equal(response.status, 302);
    +		});
    +
    +		it('should NOT allow accessing /admin with url encoding', async () => {
    +			const request = new Request('http://example.com/%61dmin');
    +			const response = await app.render(request);
    +			assert.equal(response.status, 302);
    +		});
    +
    +		it('should NOT allow accessing /admin with fully encoded path', async () => {
    +			const request = new Request('http://example.com/%61%64%6d%69%6e');
    +			const response = await app.render(request);
    +			assert.equal(response.status, 302);
    +		});
    +	});
    +
     	// keep this last
     	it('the integration should receive the path to the middleware', async () => {
     		fixture = await loadFixture({
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.