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
19 changes: 19 additions & 0 deletions e2e/cases/server/serve-assets/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,23 @@ test('should return 403 for path traversal', async ({ request, dev }) => {
const res = await request.get(`${baseUrl}/..%2f../foo.txt`);
expect(res.status()).toEqual(403);
expect(await res.text()).toContain('Forbidden');
await rsbuild.expectLog('Malicious path');
});

test('should return 400 for null byte injection', async ({ request, dev }) => {
const rsbuild = await dev();
const baseUrl = `http://127.0.0.1:${rsbuild.port}`;
const res = await request.get(`${baseUrl}/foo%00bar.js`);
expect(res.status()).toEqual(400);
expect(await res.text()).toContain('Bad Request');
await rsbuild.expectLog('Invalid pathname');
});

test('should return 400 for invalid pathname', async ({ request, dev }) => {
const rsbuild = await dev();
const baseUrl = `http://127.0.0.1:${rsbuild.port}`;
const res = await request.get(`${baseUrl}/foo%E0%A4bar.js`);
expect(res.status()).toEqual(400);
expect(await res.text()).toContain('Bad Request');
await rsbuild.expectLog('Invalid pathname');
});
23 changes: 11 additions & 12 deletions packages/core/src/server/assets-middleware/getFileFromUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,11 @@ import type { Stats as FSStats } from 'node:fs';
import path from 'node:path';
import { getPathnameFromUrl } from '../../helpers/path';
import type { InternalContext } from '../../types';
import { HttpCode } from '../helper';
import type { OutputFileSystem } from './index';

const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;

function decodePath(input: string) {
try {
return decodeURIComponent(input);
} catch {
return input;
}
}

/**
* Resolves URL to file path with security checks and retrieves file from
* the build output directories.
Expand All @@ -23,22 +16,28 @@ export async function getFileFromUrl(
outputFileSystem: OutputFileSystem,
context: InternalContext,
): Promise<
{ filename: string; fsStats: FSStats } | { errorCode: number } | undefined
{ filename: string; fsStats: FSStats } | { errorCode: HttpCode } | undefined
> {
const pathname = decodePath(getPathnameFromUrl(url));
let pathname = getPathnameFromUrl(url);

try {
pathname = decodeURIComponent(pathname);
} catch {
return { errorCode: HttpCode.BadRequest };
}

if (!pathname) {
return;
}

// Return early to prevent null byte injection attacks
if (pathname.includes('\0')) {
return { errorCode: 400 };
return { errorCode: HttpCode.BadRequest };
}

// Prevent path traversal attacks by checking for ".." patterns
if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) {
return { errorCode: 403 };
return { errorCode: HttpCode.Forbidden };
}

const stat = async (filename: string) => {
Expand Down
106 changes: 56 additions & 50 deletions packages/core/src/server/assets-middleware/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Stats as FSStats, ReadStream } from 'node:fs';
import type { ServerResponse } from 'node:http';
import onFinished from 'on-finished';
import type { Range, Result as RangeResult, Ranges } from 'range-parser';
import rangeParser from 'range-parser';
import { requireCompiledPackage } from '../../helpers/vendors';
import { logger } from '../../logger';
import type { InternalContext, RequestHandler } from '../../types';
import { HttpCode } from '../helper';
import { escapeHtml } from './escapeHtml';
import { getFileFromUrl } from './getFileFromUrl';
import type { OutputFileSystem } from './index';
Expand Down Expand Up @@ -92,14 +94,6 @@ function destroyStream(stream: ReadStream, suppress: boolean): void {
}
}

const statuses: Record<number, string> = {
400: 'Bad Request',
403: 'Forbidden',
404: 'Not Found',
416: 'Range Not Satisfiable',
500: 'Internal Server Error',
};

const parseRangeHeaders = memorize((value: string): RangeResult | Ranges => {
const [len, rangeHeader] = value.split('|');
return rangeParser(Number(len), rangeHeader, {
Expand All @@ -113,6 +107,52 @@ type SendErrorOptions = {

const acceptedMethods = ['GET', 'HEAD'];

function sendError(
res: ServerResponse,
code: HttpCode,
options?: Partial<SendErrorOptions>,
): void {
const errorMessages: Record<HttpCode, string> = {
[HttpCode.BadRequest]: 'Bad Request',
[HttpCode.Forbidden]: 'Forbidden',
[HttpCode.NotFound]: 'Not Found',
[HttpCode.PreconditionFailed]: 'Precondition Failed',
[HttpCode.RangeNotSatisfiable]: 'Range Not Satisfiable',
[HttpCode.InternalServerError]: 'Internal Server Error',
};

const content = errorMessages[code];
const document = Buffer.from(
`<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Error</title>\n</head>\n<body>\n<pre>${escapeHtml(content)}</pre>\n</body>\n</html>`,
'utf-8',
);

const headers = res.getHeaderNames();
for (let i = 0; i < headers.length; i++) {
res.removeHeader(headers[i]);
}

if (options?.headers) {
const keys = Object.keys(options.headers);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = options.headers[key];
if (typeof value !== 'undefined') {
res.setHeader(key, value);
}
}
}

res.statusCode = code;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('X-Content-Type-Options', 'nosniff');

const byteLength = Buffer.byteLength(document);
res.setHeader('Content-Length', byteLength);
res.end(document);
}

export function createMiddleware(
context: InternalContext,
ready: (callback: () => void) => void,
Expand All @@ -133,42 +173,6 @@ export function createMiddleware(
return;
}

function sendError(
status: number,
options?: Partial<SendErrorOptions>,
): void {
const content = statuses[status] || String(status);
const document = Buffer.from(
`<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Error</title>\n</head>\n<body>\n<pre>${escapeHtml(content)}</pre>\n</body>\n</html>`,
'utf-8',
);

const headers = res.getHeaderNames();
for (let i = 0; i < headers.length; i++) {
res.removeHeader(headers[i]);
}

if (options?.headers) {
const keys = Object.keys(options.headers);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = options.headers[key];
if (typeof value !== 'undefined') {
res.setHeader(key, value);
}
}
}

res.statusCode = status;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('X-Content-Type-Options', 'nosniff');

const byteLength = Buffer.byteLength(document);
res.setHeader('Content-Length', byteLength);
res.end(document);
}

function isConditionalGET() {
return (
req.headers['if-match'] ||
Expand Down Expand Up @@ -332,10 +336,12 @@ export function createMiddleware(
}

if ('errorCode' in resolved) {
if (resolved.errorCode === 403) {
if (resolved.errorCode === HttpCode.Forbidden) {
logger.error(`[rsbuild:middleware] Malicious path "${req.url}".`);
} else if (resolved.errorCode === HttpCode.BadRequest) {
logger.error(`[rsbuild:middleware] Invalid pathname "${req.url}".`);
}
sendError(resolved.errorCode);
sendError(res, resolved.errorCode);
return;
}

Expand Down Expand Up @@ -366,7 +372,7 @@ export function createMiddleware(

if (isConditionalGET()) {
if (isPreconditionFailure()) {
sendError(412);
sendError(res, HttpCode.PreconditionFailed);
return;
}

Expand Down Expand Up @@ -414,7 +420,7 @@ export function createMiddleware(
getValueContentRangeHeader('bytes', size),
);

sendError(416, {
sendError(res, HttpCode.RangeNotSatisfiable, {
headers: {
'Content-Range': res.getHeader('Content-Range'),
},
Expand Down Expand Up @@ -490,10 +496,10 @@ export function createMiddleware(
case 'ENAMETOOLONG':
case 'ENOENT':
case 'ENOTDIR':
sendError(404);
sendError(res, HttpCode.NotFound);
break;
default:
sendError(500);
sendError(res, HttpCode.InternalServerError);
break;
}
},
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/server/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,12 @@ export function escapeHtml(text: string | null | undefined): string {
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

export enum HttpCode {
BadRequest = 400,
Forbidden = 403,
NotFound = 404,
PreconditionFailed = 412,
RangeNotSatisfiable = 416,
InternalServerError = 500,
}
Loading