Skip to content

Commit 35dd70b

Browse files
authored
feat!: update getFilenameFromUrl to throw errors and return undefined when not found
1 parent 063213e commit 35dd70b

9 files changed

Lines changed: 157 additions & 111 deletions

File tree

.cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"cachable",
2424
"finalhandler",
2525
"hono",
26-
"rspack"
26+
"rspack",
27+
"malformed"
2728
],
2829
"ignorePaths": [
2930
"CHANGELOG.md",

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,18 @@ const app = new express();
459459
app.use(instance);
460460

461461
instance.waitUntilValid(() => {
462-
const filename = instance.getFilenameFromUrl("/bundle.js");
462+
let resolver;
463+
464+
try {
465+
resolved = instance.getFilenameFromUrl("/bundle.js");
466+
} catch (err) {
467+
console.log(`Error: ${err}`);
468+
}
469+
470+
if (!resolved) {
471+
console.log("Not found");
472+
return;
473+
}
463474

464475
console.log(`Filename is ${filename}`);
465476
});

src/index.js

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,19 +131,18 @@ const noop = () => {};
131131
* @template {IncomingMessage} [RequestInternal=IncomingMessage]
132132
* @template {ServerResponse} [ResponseInternal=ServerResponse]
133133
* @callback Middleware
134-
* @param {RequestInternal} req
135-
* @param {ResponseInternal} res
136-
* @param {NextFunction} next
134+
* @param {RequestInternal} req request
135+
* @param {ResponseInternal} res response
136+
* @param {NextFunction} next next function
137137
* @returns {Promise<void>}
138138
*/
139139

140140
/** @typedef {import("./utils/getFilenameFromUrl").Extra} Extra */
141141

142142
/**
143143
* @callback GetFilenameFromUrl
144-
* @param {string} url
145-
* @param {Extra=} extra
146-
* @returns {string | undefined}
144+
* @param {string} url request URL
145+
* @returns {{ filename: string, extra: Extra } | undefined} a filename with additional information, or `undefined` if nothing is found
147146
*/
148147

149148
/**
@@ -278,8 +277,7 @@ function wdm(compiler, options = {}) {
278277
(middleware(filledContext));
279278

280279
// API
281-
instance.getFilenameFromUrl = (url, extra) =>
282-
getFilenameFromUrl(filledContext, url, extra);
280+
instance.getFilenameFromUrl = (url) => getFilenameFromUrl(filledContext, url);
283281

284282
instance.waitUntilValid = (callback = noop) => {
285283
ready(filledContext, callback);

src/middleware.js

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const ready = require("./utils/ready");
3131
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
3232
/** @typedef {import("./index.js").ServerResponse} ServerResponse */
3333
/** @typedef {import("./index.js").NormalizedHeaders} NormalizedHeaders */
34+
/** @typedef {import("./utils/getFilenameFromUrl.js").FilenameError} FilenameError */
35+
/** @typedef {import("./utils/getFilenameFromUrl.js").Extra} Extra */
3436
/** @typedef {import("fs").ReadStream} ReadStream */
3537

3638
const BYTES_RANGE_REGEXP = /^ *bytes/i;
@@ -498,30 +500,37 @@ function wrapper(context) {
498500
*/
499501
async function processRequest() {
500502
// Pipe and SendFile
501-
/** @type {import("./utils/getFilenameFromUrl").Extra} */
502-
const extra = {};
503-
const filename = getFilenameFromUrl(
504-
context,
505-
/** @type {string} */ (getRequestURL(req)),
506-
extra,
507-
);
508-
509-
if (extra.errorCode) {
510-
if (extra.errorCode === 403) {
511-
context.logger.error(`Malicious path "${filename}".`);
503+
/** @type {{ filename: string, extra: Extra } | undefined} */
504+
let resolved;
505+
506+
const requestUrl = /** @type {string} */ (getRequestURL(req));
507+
508+
try {
509+
resolved = getFilenameFromUrl(context, requestUrl);
510+
} catch (err) {
511+
// Fallback to 403 for unknown errors
512+
const errorCode =
513+
typeof err === "object" &&
514+
err !== null &&
515+
typeof (/** @type {FilenameError} */ (err).code) !== "undefined"
516+
? /** @type {FilenameError} */ (err).code
517+
: 403;
518+
519+
if (errorCode === 403) {
520+
context.logger.error(`Malicious path "${requestUrl}".`);
512521
}
513522

514523
await sendError(
515-
extra.errorCode === 400 ? "Bad Request" : "Forbidden",
516-
extra.errorCode,
524+
errorCode === 400 ? "Bad Request" : "Forbidden",
525+
errorCode,
517526
{
518527
modifyResponseData: context.options.modifyResponseData,
519528
},
520529
);
521530
return;
522531
}
523532

524-
if (!filename) {
533+
if (!resolved) {
525534
await goNext();
526535
return;
527536
}
@@ -531,7 +540,8 @@ function wrapper(context) {
531540
return;
532541
}
533542

534-
const { size } = /** @type {import("fs").Stats} */ (extra.stats);
543+
const { extra, filename } = resolved;
544+
const { size } = extra.stats;
535545

536546
let len = size;
537547
let offset = 0;
@@ -609,9 +619,7 @@ function wrapper(context) {
609619
context.options.lastModified &&
610620
!getResponseHeader(res, "Last-Modified")
611621
) {
612-
const modified =
613-
/** @type {import("fs").Stats} */
614-
(extra.stats).mtime.toUTCString();
622+
const modified = extra.stats.mtime.toUTCString();
615623

616624
setResponseHeader(res, "Last-Modified", modified);
617625
}
@@ -667,7 +675,7 @@ function wrapper(context) {
667675
const result = await getETag()(
668676
isStrongETag
669677
? /** @type {Buffer | ReadStream} */ (bufferOrStream)
670-
: /** @type {import("fs").Stats} */ (extra.stats),
678+
: extra.stats,
671679
);
672680

673681
// Because we already read stream, we can cache buffer to avoid extra read from fs

src/utils/getFilenameFromUrl.js

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
const path = require("node:path");
22
const querystring = require("node:querystring");
3-
// eslint-disable-next-line n/no-deprecated-api
4-
const { parse } = require("node:url");
53

64
const getPaths = require("./getPaths");
75
const memorize = require("./memorize");
@@ -17,20 +15,18 @@ function decode(input) {
1715
return querystring.unescape(input);
1816
}
1917

20-
const memoizedParse = memorize(parse, undefined, (value) => {
21-
if (value.pathname) {
22-
value.pathname = decode(value.pathname);
23-
}
18+
const memoizedParse = memorize((url) => {
19+
const urlObject = new URL(url, "http://localhost");
2420

25-
return value;
26-
});
21+
// We can't change pathname in URL object directly because don't decode correctly
22+
return { ...urlObject, pathname: decode(urlObject.pathname) };
23+
}, undefined);
2724

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

3027
/**
3128
* @typedef {object} Extra
32-
* @property {import("fs").Stats=} stats stats
33-
* @property {number=} errorCode error code
29+
* @property {import("fs").Stats} stats stats
3430
* @property {boolean=} immutable true when immutable, otherwise false
3531
*/
3632

@@ -42,43 +38,55 @@ const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
4238
* @returns {string}
4339
*/
4440

45-
// TODO refactor me in the next major release, this function should return `{ filename, stats, error }`
41+
class FilenameError extends Error {
42+
/**
43+
* @param {string} message message
44+
* @param {number=} code error code
45+
*/
46+
constructor(message, code) {
47+
super(message);
48+
this.name = "FilenameError";
49+
this.code = code;
50+
}
51+
}
52+
4653
// TODO fix redirect logic when `/` at the end, like https://github.com/pillarjs/send/blob/master/index.js#L586
4754
/**
4855
* @template {IncomingMessage} Request
4956
* @template {ServerResponse} Response
5057
* @param {import("../index.js").FilledContext<Request, Response>} context context
5158
* @param {string} url url
52-
* @param {Extra=} extra extra
53-
* @returns {string | undefined} filename
59+
* @returns {{ filename: string, extra: Extra } | undefined} result of get filename from url
5460
*/
55-
function getFilenameFromUrl(context, url, extra = {}) {
56-
const { options } = context;
57-
const paths = getPaths(context);
61+
function getFilenameFromUrl(context, url) {
62+
/** @type {URL} */
63+
let urlObject;
5864

5965
/** @type {string | undefined} */
6066
let foundFilename;
61-
/** @type {import("node:url").Url} */
62-
let urlObject;
6367

6468
try {
6569
// The `url` property of the `request` is contains only `pathname`, `search` and `hash`
66-
urlObject = memoizedParse(url, false, true);
70+
urlObject = memoizedParse(url);
6771
} catch {
6872
return;
6973
}
7074

75+
const { options } = context;
76+
const paths = getPaths(context);
77+
78+
/** @type {Extra} */
79+
const extra = {};
80+
7181
for (const { publicPath, outputPath, assetsInfo } of paths) {
7282
/** @type {string | undefined} */
7383
let filename;
74-
/** @type {import("node:url").Url} */
84+
/** @type {URL} */
7585
let publicPathObject;
7686

7787
try {
7888
publicPathObject = memoizedParse(
7989
publicPath !== "auto" && publicPath ? publicPath : "/",
80-
false,
81-
true,
8290
);
8391
} catch {
8492
continue;
@@ -94,16 +102,12 @@ function getFilenameFromUrl(context, url, extra = {}) {
94102
) {
95103
// Null byte(s)
96104
if (pathname.includes("\0")) {
97-
extra.errorCode = 400;
98-
99-
return;
105+
throw new FilenameError("Bad Request", 400);
100106
}
101107

102108
// ".." is malicious
103109
if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) {
104-
extra.errorCode = 403;
105-
106-
return;
110+
throw new FilenameError("Forbidden", 403);
107111
}
108112

109113
// Strip the `pathname` property from the `publicPath` option from the start of requested url
@@ -161,7 +165,12 @@ function getFilenameFromUrl(context, url, extra = {}) {
161165
}
162166
}
163167

164-
return foundFilename;
168+
if (!foundFilename) {
169+
return;
170+
}
171+
172+
return { filename: foundFilename, extra };
165173
}
166174

167175
module.exports = getFilenameFromUrl;
176+
module.exports.FilenameError = FilenameError;

0 commit comments

Comments
 (0)