Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions packages/docusaurus/src/commands/serve/__tests__/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string> {
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<string> {
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));
Expand Down Expand Up @@ -106,25 +117,57 @@ function respondWithCrash(
async function describeRequest(
origin: string,
requestPath: string,
{asIs = false}: RequestOptions = {},
): Promise<string> {
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<Response> {
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<string> {
const body = await response.text();

Expand Down
Loading