diff --git a/e2e/cases/server/serve-assets/index.test.ts b/e2e/cases/server/serve-assets/index.test.ts index 109df11e07..eca04cc181 100644 --- a/e2e/cases/server/serve-assets/index.test.ts +++ b/e2e/cases/server/serve-assets/index.test.ts @@ -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'); }); diff --git a/packages/core/src/server/assets-middleware/getFileFromUrl.ts b/packages/core/src/server/assets-middleware/getFileFromUrl.ts index 613835db70..e7d8b03e75 100644 --- a/packages/core/src/server/assets-middleware/getFileFromUrl.ts +++ b/packages/core/src/server/assets-middleware/getFileFromUrl.ts @@ -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. @@ -23,9 +16,15 @@ 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; @@ -33,12 +32,12 @@ export async function getFileFromUrl( // 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) => { diff --git a/packages/core/src/server/assets-middleware/middleware.ts b/packages/core/src/server/assets-middleware/middleware.ts index d59157de9c..6559b9cd58 100644 --- a/packages/core/src/server/assets-middleware/middleware.ts +++ b/packages/core/src/server/assets-middleware/middleware.ts @@ -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'; @@ -92,14 +94,6 @@ function destroyStream(stream: ReadStream, suppress: boolean): void { } } -const statuses: Record = { - 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, { @@ -113,6 +107,52 @@ type SendErrorOptions = { const acceptedMethods = ['GET', 'HEAD']; +function sendError( + res: ServerResponse, + code: HttpCode, + options?: Partial, +): void { + const errorMessages: Record = { + [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( + `\n\n\n\nError\n\n\n
${escapeHtml(content)}
\n\n`, + '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, @@ -133,42 +173,6 @@ export function createMiddleware( return; } - function sendError( - status: number, - options?: Partial, - ): void { - const content = statuses[status] || String(status); - const document = Buffer.from( - `\n\n\n\nError\n\n\n
${escapeHtml(content)}
\n\n`, - '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'] || @@ -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; } @@ -366,7 +372,7 @@ export function createMiddleware( if (isConditionalGET()) { if (isPreconditionFailure()) { - sendError(412); + sendError(res, HttpCode.PreconditionFailed); return; } @@ -414,7 +420,7 @@ export function createMiddleware( getValueContentRangeHeader('bytes', size), ); - sendError(416, { + sendError(res, HttpCode.RangeNotSatisfiable, { headers: { 'Content-Range': res.getHeader('Content-Range'), }, @@ -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; } }, diff --git a/packages/core/src/server/helper.ts b/packages/core/src/server/helper.ts index 0c28c0b294..9acfbd28fa 100644 --- a/packages/core/src/server/helper.ts +++ b/packages/core/src/server/helper.ts @@ -517,3 +517,12 @@ export function escapeHtml(text: string | null | undefined): string { .replace(/"/g, '"') .replace(/'/g, '''); } + +export enum HttpCode { + BadRequest = 400, + Forbidden = 403, + NotFound = 404, + PreconditionFailed = 412, + RangeNotSatisfiable = 416, + InternalServerError = 500, +}