From 4d0c4fcde0edbbe81d76585a5bd6248ecfc59d43 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:59:37 -0400 Subject: [PATCH 1/5] fix: preserve stripped path prefixes in trailing-slash redirects --- .changeset/tidy-pumas-travel.md | 6 ++++ packages/adapter-node/src/handler.js | 17 +++++++++-- packages/kit/src/runtime/server/respond.js | 12 +++++--- packages/kit/src/utils/url.js | 12 ++++++++ packages/kit/src/utils/url.spec.js | 24 ++++++++++++++++ packages/kit/test/apps/options/test/test.js | 31 ++++++++++++++++----- 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 .changeset/tidy-pumas-travel.md diff --git a/.changeset/tidy-pumas-travel.md b/.changeset/tidy-pumas-travel.md new file mode 100644 index 000000000000..cee4088e7800 --- /dev/null +++ b/.changeset/tidy-pumas-travel.md @@ -0,0 +1,6 @@ +--- +'@sveltejs/kit': patch +'@sveltejs/adapter-node': patch +--- + +fix: preserve stripped path prefixes by making trailing-slash redirects relative diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index a32a63007332..853b0a87803e 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -60,6 +60,18 @@ function serve(path, client = false) { : undefined; } +/** + * @param {string} from + * @param {string} to + * @returns {string} + */ +function relative_pathname(from, to) { + const pathname = from.endsWith('/') ? to : from; + const segment = pathname.slice(pathname.lastIndexOf('/') + 1); + + return from.endsWith('/') ? `../${segment}` : `${segment}/`; +} + // required because the static file server ignores trailing slashes /** @returns {import('polka').Middleware} */ function serve_prerendered() { @@ -79,8 +91,9 @@ function serve_prerendered() { } // remove or add trailing slash as appropriate - let location = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; - if (prerendered.has(location)) { + const inverted = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; + if (prerendered.has(inverted)) { + let location = relative_pathname(pathname, inverted); if (query) location += search; res.writeHead(308, { location }).end(); } else { diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 188eab860e9e..4d6b101b0264 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -11,7 +11,12 @@ import { respond_with_error } from './page/respond_with_error.js'; import { get_self_origin, is_csrf_forbidden, is_remote_forbidden } from './csrf.js'; import { has_prerendered_path, method_not_allowed, redirect_response } from './utils.js'; import { handle_fatal_error } from './errors.js'; -import { decode_pathname, disable_search, normalize_path } from '../../utils/url.js'; +import { + decode_pathname, + disable_search, + normalize_path, + relative_pathname +} from '../../utils/url.js'; import { find_route } from '../../utils/routing.js'; import { redirect_json_response, render_data } from './data/index.js'; import { add_cookies_to_headers, get_cookies } from './cookie.js'; @@ -378,10 +383,9 @@ export async function internal_respond(request, options, manifest, state) { status: 308, headers: { 'x-sveltekit-normalize': '1', + // relative so (possibly invisible) path prefixes are preserved location: - // ensure paths starting with '//' are not treated as protocol-relative - (normalized.startsWith('//') ? url.origin + normalized : normalized) + - (url.search === '?' ? '' : url.search) + relative_pathname(url.pathname, normalized) + (url.search === '?' ? '' : url.search) } }); } diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index 42b4d9f3bea1..bed08d21ef2c 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -27,6 +27,18 @@ export function is_root_relative(path) { return path[0] === '/' && path[1] !== '/'; } +/** + * @param {string} from + * @param {string} to + * @returns {string} + */ +export function relative_pathname(from, to) { + const pathname = from.endsWith('/') ? to : from; + const segment = pathname.slice(pathname.lastIndexOf('/') + 1); + + return from.endsWith('/') ? `../${segment}` : `${segment}/`; +} + /** * @param {string} location * @param {string} allowed diff --git a/packages/kit/src/utils/url.spec.js b/packages/kit/src/utils/url.spec.js index 69dcb23a37c3..25e3f6b8d853 100644 --- a/packages/kit/src/utils/url.spec.js +++ b/packages/kit/src/utils/url.spec.js @@ -2,6 +2,7 @@ import { assert, describe } from 'vitest'; import { resolve, normalize_path, + relative_pathname, make_trackable, disable_search, matches_external_allowlist_entry @@ -73,6 +74,29 @@ describe('resolve', (test) => { }); }); +describe('relative_pathname', (test) => { + test('converts trailing-slash redirects to relative URL references', () => { + const cases = [ + ['/a/b', '/a/b/', 'b/'], + ['/a/b/', '/a/b', '../b'], + ['/path-base/slash', '/path-base/slash/', 'slash/'], + ['//x', '//x/', 'x/'], + ['//x/', '//x', '../x'], + ['/a/b%2Fc', '/a/b%2Fc/', 'b%2Fc/'] + ]; + + for (const [from, to, expected] of cases) { + const result = relative_pathname(from, to); + const base = new URL('http://internal'); + base.pathname = from; + + assert.equal(result, expected); + assert.equal(result.startsWith('/'), false); + assert.equal(new URL(result, base).pathname, to); + } + }); +}); + describe('matches_external_allowlist_entry', (test) => { test('matches allowed origins', () => { assert.equal(matches_external_allowlist_entry('https://google.de', 'https://google.de'), true); diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index d460728f4e4b..46789b320889 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -108,14 +108,18 @@ test.describe('env', () => { test.describe('trailingSlash', () => { test('adds trailing slash', async ({ baseURL, page, clicknav }) => { // we can't use Playwright's `request` here, because it resolves redirects - const status = await new Promise((fulfil, reject) => { - const request = http.get(`${baseURL}/path-base/slash`); - request.on('error', reject); - request.on('response', (response) => { - fulfil(response.statusCode); - }); - }); + const [status, location] = + await /** @type {Promise<[number | undefined, string | undefined]>} */ ( + new Promise((fulfil, reject) => { + const request = http.get(`${baseURL}/path-base/slash`); + request.on('error', reject); + request.on('response', (response) => { + fulfil([response.statusCode, response.headers.location]); + }); + }) + ); expect(status).toBe(308); + expect(location).toBe('slash/'); await page.goto('/path-base/slash'); @@ -128,6 +132,19 @@ test.describe('trailingSlash', () => { }); test('removes trailing slash on endpoint', async ({ baseURL, request }) => { + const [status, location] = + await /** @type {Promise<[number | undefined, string | undefined]>} */ ( + new Promise((fulfil, reject) => { + const request = http.get(`${baseURL}/path-base/endpoint/`); + request.on('error', reject); + request.on('response', (response) => { + fulfil([response.statusCode, response.headers.location]); + }); + }) + ); + expect(status).toBe(308); + expect(location).toBe('../endpoint'); + const r1 = await request.get('/path-base/endpoint/'); expect(r1.url()).toBe(`${baseURL}/path-base/endpoint`); expect(await r1.text()).toBe('hi'); From 0b911132610c9464ef5b4bc337a6930232b774a1 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:18:01 -0400 Subject: [PATCH 2/5] chore: document relative_pathname input invariant Co-authored-by: HoldYourWaffle --- packages/adapter-node/src/handler.js | 1 + packages/kit/src/utils/url.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 853b0a87803e..a64fd113d571 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -61,6 +61,7 @@ function serve(path, client = false) { } /** + * Relative reference from `from` to `to`, which must differ only by a trailing slash * @param {string} from * @param {string} to * @returns {string} diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index bed08d21ef2c..fb8c0c44ae19 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -28,6 +28,7 @@ export function is_root_relative(path) { } /** + * Relative reference from `from` to `to`, which must differ only by a trailing slash * @param {string} from * @param {string} to * @returns {string} From 62e9132f2f25579b35a7f4f3fc46e3489e92ca06 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:00:40 -0400 Subject: [PATCH 3/5] test: use fetch with manual redirect --- packages/kit/test/apps/options/test/test.js | 31 ++++----------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index 46789b320889..895cb9ef6212 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -1,4 +1,3 @@ -import * as http from 'node:http'; import process from 'node:process'; import { expect } from '@playwright/test'; import { test } from '../../../utils.js'; @@ -108,18 +107,9 @@ test.describe('env', () => { test.describe('trailingSlash', () => { test('adds trailing slash', async ({ baseURL, page, clicknav }) => { // we can't use Playwright's `request` here, because it resolves redirects - const [status, location] = - await /** @type {Promise<[number | undefined, string | undefined]>} */ ( - new Promise((fulfil, reject) => { - const request = http.get(`${baseURL}/path-base/slash`); - request.on('error', reject); - request.on('response', (response) => { - fulfil([response.statusCode, response.headers.location]); - }); - }) - ); - expect(status).toBe(308); - expect(location).toBe('slash/'); + const response = await fetch(`${baseURL}/path-base/slash`, { redirect: 'manual' }); + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe('slash/'); await page.goto('/path-base/slash'); @@ -132,18 +122,9 @@ test.describe('trailingSlash', () => { }); test('removes trailing slash on endpoint', async ({ baseURL, request }) => { - const [status, location] = - await /** @type {Promise<[number | undefined, string | undefined]>} */ ( - new Promise((fulfil, reject) => { - const request = http.get(`${baseURL}/path-base/endpoint/`); - request.on('error', reject); - request.on('response', (response) => { - fulfil([response.statusCode, response.headers.location]); - }); - }) - ); - expect(status).toBe(308); - expect(location).toBe('../endpoint'); + const response = await fetch(`${baseURL}/path-base/endpoint/`, { redirect: 'manual' }); + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe('../endpoint'); const r1 = await request.get('/path-base/endpoint/'); expect(r1.url()).toBe(`${baseURL}/path-base/endpoint`); From fda07c5fb81f3f9206140298f9b791dbd36a0639 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:00:40 -0400 Subject: [PATCH 4/5] chore: simplify prerendered redirect location --- packages/adapter-node/src/handler.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index a64fd113d571..c7c8ea6d4bca 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -94,8 +94,7 @@ function serve_prerendered() { // remove or add trailing slash as appropriate const inverted = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; if (prerendered.has(inverted)) { - let location = relative_pathname(pathname, inverted); - if (query) location += search; + const location = relative_pathname(pathname, inverted) + (query ? search : ''); res.writeHead(308, { location }).end(); } else { void next(); From f96bef2cccbea3db324016b826aada124da94224 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:11:56 -0400 Subject: [PATCH 5/5] chore: simplify relative_pathname --- packages/adapter-node/src/handler.js | 6 +++--- packages/kit/src/utils/url.js | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index c7c8ea6d4bca..e059b200d727 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -61,14 +61,14 @@ function serve(path, client = false) { } /** - * Relative reference from `from` to `to`, which must differ only by a trailing slash + * Relative reference from `from` to `to`, which must differ only by a trailing slash. + * Keep in sync with the copy in `packages/kit/src/utils/url.js` * @param {string} from * @param {string} to * @returns {string} */ function relative_pathname(from, to) { - const pathname = from.endsWith('/') ? to : from; - const segment = pathname.slice(pathname.lastIndexOf('/') + 1); + const segment = to.replace(/\/$/, '').split('/').at(-1); return from.endsWith('/') ? `../${segment}` : `${segment}/`; } diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index fb8c0c44ae19..0e44ad3591aa 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -34,8 +34,7 @@ export function is_root_relative(path) { * @returns {string} */ export function relative_pathname(from, to) { - const pathname = from.endsWith('/') ? to : from; - const segment = pathname.slice(pathname.lastIndexOf('/') + 1); + const segment = to.replace(/\/$/, '').split('/').at(-1); return from.endsWith('/') ? `../${segment}` : `${segment}/`; }