From 16b730346b7973ca68ba14cc280fa81efc63d29a Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 14:52:02 +0100 Subject: [PATCH 1/3] test(core): capture how docusaurus serve handles hand-typed URLs (AI-assisted) fetch() normalizes request URLs like browsers do, so the tests could not send a #hash or a ".." segment. Hand-typed requests can, and the serve request handler sees them as-is. A new test option sends the first request with http.get() to capture this behavior. Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 99 +++++++++++++++++++ .../src/commands/serve/__tests__/testUtils.ts | 44 ++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 0a3e5893deec..6318a2c57f39 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -69,6 +69,20 @@ const RequestPaths = [ '/..%2Fdocusaurus.config.js', // Path traversal ]; +// Appended to the baseUrl. Browsers normalize these URLs before sending them, +// but hand-typed requests can send them as-is +const HandTypedRequestPaths = [ + '/file.html#hash', + '/file.html?query=1#hash', + '/file#hash.html', + '/folder/index#hash', + '/assets/css/styles.css#hash', + '/./file.html', + '/folder/../file.html', + '/../file.html', + '/%2E%2E/file.html', +]; + describe('createServeRequestHandler', () => { describe('baseUrl /', () => { it('trailingSlash undefined', async () => { @@ -427,6 +441,91 @@ describe('createServeRequestHandler', () => { /basement 302 /base/ → 200 index.html" `); }); + + describe('hand-typed URLs', () => { + it('trailingSlash undefined', async () => { + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: undefined}, + HandTypedRequestPaths, + {asIs: true}, + ); + expect(result).toMatchInlineSnapshot(` + "/base/file.html#hash 301 /file → 302 /base/ → 200 index.html + /base/file.html?query=1#hash 301 /file → 302 /base/ → 200 index.html + /base/file#hash.html 200 file.html + /base/folder/index#hash 301 /folder → 302 /base/ → 200 index.html + /base/assets/css/styles.css#hash 200 assets/css/styles.css + /base/./file.html 301 /./file → 302 /base/ → 200 index.html + /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html + /base/../file.html 400 Bad Request + /base/%2E%2E/file.html 400 Bad Request" + `); + }); + + // Windows serves file URLs with a trailing slash, other platforms 404 + it.skipIf(process.platform === 'win32')( + 'trailingSlash true', + async () => { + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: true}, + HandTypedRequestPaths, + {asIs: true}, + ); + expect(result).toMatchInlineSnapshot(` + "/base/file.html#hash 302 /base/file.html/#hash → 404 404.html + /base/file.html?query=1#hash 302 /base/file.html/?query=1#hash → 404 404.html + /base/file#hash.html 301 /file/ → 302 /base/ → 200 index.html + /base/folder/index#hash 302 /base/folder/index/#hash → 200 folder/index.html + /base/assets/css/styles.css#hash 302 /base/assets/css/styles.css/#hash → 404 404.html + /base/./file.html 301 /./file → 302 /base/ → 200 index.html + /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html + /base/../file.html 400 Bad Request + /base/%2E%2E/file.html 400 Bad Request" + `); + }, + ); + + it.skipIf(process.platform !== 'win32')( + 'trailingSlash true, on Windows', + async () => { + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: true}, + HandTypedRequestPaths, + {asIs: true}, + ); + expect(result).toMatchInlineSnapshot(` + "/base/file.html#hash 302 /base/file.html/#hash → 200 file.html + /base/file.html?query=1#hash 302 /base/file.html/?query=1#hash → 200 file.html + /base/file#hash.html 301 /file/ → 302 /base/ → 200 index.html + /base/folder/index#hash 302 /base/folder/index/#hash → 200 folder/index.html + /base/assets/css/styles.css#hash 302 /base/assets/css/styles.css/#hash → 200 assets/css/styles.css + /base/./file.html 301 /./file → 302 /base/ → 200 index.html + /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html + /base/../file.html 400 Bad Request + /base/%2E%2E/file.html 400 Bad Request" + `); + }, + ); + + it('trailingSlash false', async () => { + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: false}, + HandTypedRequestPaths, + {asIs: true}, + ); + expect(result).toMatchInlineSnapshot(` + "/base/file.html#hash 301 /file → 302 /base/ → 200 index.html + /base/file.html?query=1#hash 301 /file → 302 /base/ → 200 index.html + /base/file#hash.html 200 file.html + /base/folder/index#hash 301 /folder → 302 /base/ → 200 index.html + /base/assets/css/styles.css#hash 200 assets/css/styles.css + /base/./file.html 301 /./file → 302 /base/ → 200 index.html + /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html + /base/../file.html 400 Bad Request + /base/%2E%2E/file.html 400 Bad Request" + `); + }); + }); }); // These tests capture the buggy behavior, also visible in the snapshots above diff --git a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts index 523c9f973791..b16dd8aca7cb 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts @@ -5,8 +5,10 @@ * LICENSE file in the root directory of this source tree. */ +import {once} from 'node:events'; import http from 'node:http'; import path from 'node:path'; +import {buffer} from 'node:stream/consumers'; import {pathExists} from '@docusaurus/fs'; import {listenToServer} from '../listenToServer'; import {createServeRequestHandler} from '../requestHandler'; @@ -22,24 +24,33 @@ const CrashHeader = 'x-test-crash'; const MaxRedirects = 5; +type RequestOptions = { + // Send the request paths as-is, without normalizing them like browsers do + asIs?: boolean; +}; + // The request paths are appended to the baseUrl export async function describeServing( options: ServeOptions, requestPaths: string[], + requestOptions: RequestOptions = {}, ): Promise { const baseUrlPrefix = options.baseUrl.replace(/\/$/, ''); const paths = requestPaths.map((requestPath) => baseUrlPrefix + requestPath); - return describeRequests(options, paths); + return describeRequests(options, paths, requestOptions); } // One line per request, aligned to be easy to review in snapshots export async function describeRequests( options: ServeOptions, paths: string[], + requestOptions: RequestOptions = {}, ): Promise { const descriptions = await withServer(options, (origin) => Promise.all( - paths.map((requestPath) => describeRequest(origin, requestPath)), + paths.map((requestPath) => + describeRequest(origin, requestPath, requestOptions), + ), ), ); const pathWidth = Math.max(...paths.map((requestPath) => requestPath.length)); @@ -106,13 +117,19 @@ function respondWithCrash( async function describeRequest( origin: string, requestPath: string, + {asIs = false}: RequestOptions = {}, ): Promise { const descriptions: string[] = []; // Concatenated: new URL('//file', origin) would read "file" as a host name let url = origin + requestPath; while (descriptions.length <= MaxRedirects) { - const response = await fetch(url, {redirect: 'manual'}); + // Browsers normalize the redirect URLs they follow + const isFirstRequest = descriptions.length === 0; + const response = + asIs && isFirstRequest + ? await fetchAsIs(origin, requestPath) + : await fetch(url, {redirect: 'manual'}); descriptions.push(await describeResponse(response)); const location = response.headers.get('location'); @@ -125,6 +142,27 @@ async function describeRequest( return descriptions.join(' → '); } +// fetch() normalizes the URL like browsers do: it resolves "..", drops the +// #hash... http.get() sends the path as-is, like a hand-typed request +async function fetchAsIs( + origin: string, + requestPath: string, +): Promise { + const {hostname, port} = new URL(origin); + const request = http.get({hostname, port, path: requestPath}); + const [res] = (await once(request, 'response')) as [http.IncomingMessage]; + + const headers = new Headers(); + for (const [name, values] of Object.entries(res.headersDistinct)) { + values?.forEach((value) => headers.append(name, value)); + } + return new Response(await buffer(res), { + status: res.statusCode, + statusText: res.statusMessage, + headers, + }); +} + async function describeResponse(response: Response): Promise { const body = await response.text(); From 3b3afd67f405dd96aa52ecd7e58c79debe69da06 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 15:00:36 +0100 Subject: [PATCH 2/3] test(core): capture docusaurus serve open redirect attempts (AI-assisted) Hand-typed URLs whose ".." segments resolve to "//example.com" must not redirect to another host. The tests also never follow redirects to other hosts, so a regression can't make them send network requests. Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 19 +++++++++++++++---- .../src/commands/serve/__tests__/testUtils.ts | 7 ++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 6318a2c57f39..6a5d9f30d55d 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -81,6 +81,9 @@ const HandTypedRequestPaths = [ '/folder/../file.html', '/../file.html', '/%2E%2E/file.html', + // Open redirect attempts + '/..//example.com/index', + '/..//example.com/file', ]; describe('createServeRequestHandler', () => { @@ -458,7 +461,9 @@ describe('createServeRequestHandler', () => { /base/./file.html 301 /./file → 302 /base/ → 200 index.html /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html /base/../file.html 400 Bad Request - /base/%2E%2E/file.html 400 Bad Request" + /base/%2E%2E/file.html 400 Bad Request + /base/..//example.com/index 400 Bad Request + /base/..//example.com/file 400 Bad Request" `); }); @@ -480,7 +485,9 @@ describe('createServeRequestHandler', () => { /base/./file.html 301 /./file → 302 /base/ → 200 index.html /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html /base/../file.html 400 Bad Request - /base/%2E%2E/file.html 400 Bad Request" + /base/%2E%2E/file.html 400 Bad Request + /base/..//example.com/index 302 /base/..//example.com/index/ → 302 /base/ → 200 index.html + /base/..//example.com/file 302 /base/..//example.com/file/ → 302 /base/ → 200 index.html" `); }, ); @@ -502,7 +509,9 @@ describe('createServeRequestHandler', () => { /base/./file.html 301 /./file → 302 /base/ → 200 index.html /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html /base/../file.html 400 Bad Request - /base/%2E%2E/file.html 400 Bad Request" + /base/%2E%2E/file.html 400 Bad Request + /base/..//example.com/index 302 /base/..//example.com/index/ → 302 /base/ → 200 index.html + /base/..//example.com/file 302 /base/..//example.com/file/ → 302 /base/ → 200 index.html" `); }, ); @@ -522,7 +531,9 @@ describe('createServeRequestHandler', () => { /base/./file.html 301 /./file → 302 /base/ → 200 index.html /base/folder/../file.html 301 /folder/../file → 302 /base/ → 200 index.html /base/../file.html 400 Bad Request - /base/%2E%2E/file.html 400 Bad Request" + /base/%2E%2E/file.html 400 Bad Request + /base/..//example.com/index 400 Bad Request + /base/..//example.com/file 400 Bad Request" `); }); }); diff --git a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts index b16dd8aca7cb..f09a71420bbf 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts @@ -136,7 +136,12 @@ async function describeRequest( if (location === null) { break; } - url = new URL(location, url).href; + const nextUrl = new URL(location, url); + // Never follow open redirects to other hosts + if (nextUrl.origin !== origin) { + break; + } + url = nextUrl.href; } return descriptions.join(' → '); From 38877810d14766a1726585cc344b7847a7b0daa9 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 15:02:25 +0100 Subject: [PATCH 3/3] test(core): explain why serve tests cover hand-typed URLs (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/__tests__/requestHandler.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 6a5d9f30d55d..4ba4bb310f16 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -71,6 +71,8 @@ const RequestPaths = [ // Appended to the baseUrl. Browsers normalize these URLs before sending them, // but hand-typed requests can send them as-is +// Not strictly needed for a local preview server, but useful if we ever expose +// the serve request handler as a Node API const HandTypedRequestPaths = [ '/file.html#hash', '/file.html?query=1#hash',