From fb57885ef5c6fcac5096f77d582023fb7d58af43 Mon Sep 17 00:00:00 2001 From: killa Date: Wed, 20 May 2026 20:00:33 +0800 Subject: [PATCH 1/2] fix(koa): sanitize protocol-relative redirects --- packages/koa/src/response.ts | 22 +++++++++++++++++---- packages/koa/test/response/redirect.test.ts | 21 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/koa/src/response.ts b/packages/koa/src/response.ts index 4115953f34..d95cbfbd90 100644 --- a/packages/koa/src/response.ts +++ b/packages/koa/src/response.ts @@ -18,6 +18,23 @@ import type { Application } from './application.ts'; import type { Context } from './context.ts'; import type { Request } from './request.ts'; +function formatRedirectUrl(url: string): string { + if (url.startsWith('https://') || url.startsWith('http://')) { + // formatting url again avoid security escapes + return new URL(url).toString(); + } + if (url.startsWith('/\\')) { + return `/%5C${url.slice(2)}`; + } + if (/^[\\/]{2,}/.test(url)) { + return `/%2F${url.slice(2)}`; + } + if (url.startsWith('\\')) { + return `/%5C${url.slice(1)}`; + } + return url; +} + export class Response { [key: symbol]: unknown; app: Application; @@ -250,10 +267,7 @@ export class Response { if (url === 'back') { url = this._getBackReferrer() || alt || '/'; } - if (url.startsWith('https://') || url.startsWith('http://')) { - // formatting url again avoid security escapes - url = new URL(url).toString(); - } + url = formatRedirectUrl(url); this.set('Location', encodeUrl(url)); // status diff --git a/packages/koa/test/response/redirect.test.ts b/packages/koa/test/response/redirect.test.ts index e1e1dbb012..8fa10beebb 100644 --- a/packages/koa/test/response/redirect.test.ts +++ b/packages/koa/test/response/redirect.test.ts @@ -21,6 +21,27 @@ describe('ctx.redirect(url)', () => { assert.equal(ctx.status, 302); }); + it('should not redirect to protocol-relative URLs', () => { + const ctx = context(); + ctx.redirect('//evil.com'); + assert.equal(ctx.response.header.location, '/%2Fevil.com'); + assert.equal(ctx.status, 302); + }); + + it('should not redirect to backslash-prefixed URLs', () => { + const ctx = context(); + ctx.redirect(String.raw`/\evil.com`); + assert.equal(ctx.response.header.location, '/%5Cevil.com'); + assert.equal(ctx.status, 302); + }); + + it('should not redirect to slash and backslash variants', () => { + const ctx = context(); + ctx.redirect(String.raw`\//evil.com`); + assert.equal(ctx.response.header.location, '/%2F/evil.com'); + assert.equal(ctx.status, 302); + }); + it('should auto fix not encode url', async () => { const app = new Koa(); From f5c17f1482a9f40bd99cbbe7759100e7b9035ba9 Mon Sep 17 00:00:00 2001 From: killa Date: Wed, 20 May 2026 20:08:29 +0800 Subject: [PATCH 2/2] test(koa): cover redirect edge cases --- packages/koa/src/response.ts | 10 ++++++++-- packages/koa/test/response/redirect.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/koa/src/response.ts b/packages/koa/src/response.ts index d95cbfbd90..e374d222c7 100644 --- a/packages/koa/src/response.ts +++ b/packages/koa/src/response.ts @@ -21,12 +21,18 @@ import type { Request } from './request.ts'; function formatRedirectUrl(url: string): string { if (url.startsWith('https://') || url.startsWith('http://')) { // formatting url again avoid security escapes - return new URL(url).toString(); + try { + return new URL(url).toString(); + } catch { + return url; + } } if (url.startsWith('/\\')) { return `/%5C${url.slice(2)}`; } - if (/^[\\/]{2,}/.test(url)) { + const firstChar = url[0]; + const secondChar = url[1]; + if ((firstChar === '/' || firstChar === '\\') && (secondChar === '/' || secondChar === '\\')) { return `/%2F${url.slice(2)}`; } if (url.startsWith('\\')) { diff --git a/packages/koa/test/response/redirect.test.ts b/packages/koa/test/response/redirect.test.ts index 8fa10beebb..eee198dfa8 100644 --- a/packages/koa/test/response/redirect.test.ts +++ b/packages/koa/test/response/redirect.test.ts @@ -42,6 +42,20 @@ describe('ctx.redirect(url)', () => { assert.equal(ctx.status, 302); }); + it('should not redirect to double-backslash URLs', () => { + const ctx = context(); + ctx.redirect(String.raw`\\evil.com`); + assert.equal(ctx.response.header.location, '/%2Fevil.com'); + assert.equal(ctx.status, 302); + }); + + it('should not throw on malformed absolute URLs', () => { + const ctx = context(); + ctx.redirect('http://[invalid'); + assert.equal(ctx.response.header.location, 'http://[invalid'); + assert.equal(ctx.status, 302); + }); + it('should auto fix not encode url', async () => { const app = new Koa();