diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 0a3e5893deec..4ba4bb310f16 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -69,6 +69,25 @@ 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 +// 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', + '/file#hash.html', + '/folder/index#hash', + '/assets/css/styles.css#hash', + '/./file.html', + '/folder/../file.html', + '/../file.html', + '/%2E%2E/file.html', + // Open redirect attempts + '/..//example.com/index', + '/..//example.com/file', +]; + describe('createServeRequestHandler', () => { describe('baseUrl /', () => { it('trailingSlash undefined', async () => { @@ -427,6 +446,99 @@ 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 + /base/..//example.com/index 400 Bad Request + /base/..//example.com/file 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 + /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" + `); + }, + ); + + 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 + /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" + `); + }, + ); + + 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 + /base/..//example.com/index 400 Bad Request + /base/..//example.com/file 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..f09a71420bbf 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,25 +117,57 @@ 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'); 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(' → '); } +// 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();