@@ -13,7 +13,8 @@ const messages = {
1313 notFound : 'File not found' ,
1414 notFoundWithRef : 'File not found: {file}' ,
1515 cannotRead : 'Could not read file: {file}' ,
16- invalidUrlParameter : `The URL "{url}" is not a valid URL for this site.`
16+ invalidUrlParameter : `The URL "{url}" is not a valid URL for this site.` ,
17+ invalidPathParameter : 'The path "{path}" is not valid for this storage.'
1718} ;
1819
1920class LocalStorageBase extends StorageBase {
@@ -38,6 +39,75 @@ class LocalStorageBase extends StorageBase {
3839 this . errorMessages = errorMessages || messages ;
3940 }
4041
42+ /**
43+ * Normalizes a relative storage path and rejects traversal outside the storage root.
44+ *
45+ * @param {string } filePath
46+ * @returns {string }
47+ */
48+ _normalizeStorageRelativePath ( filePath ) {
49+ const normalized = path . posix . normalize ( String ( filePath || '' )
50+ . replaceAll ( '\\' , '/' )
51+ . replace ( / ^ \/ + / , '' )
52+ . replace ( / \/ + $ / , '' ) ) ;
53+
54+ if ( normalized === '.' || normalized === '..' || normalized . startsWith ( '../' ) ) {
55+ throw new errors . IncorrectUsageError ( {
56+ message : tpl ( messages . invalidPathParameter , { path : filePath } )
57+ } ) ;
58+ }
59+
60+ return normalized ;
61+ }
62+
63+ /**
64+ * Resolves a target directory and optional file name into a full path,
65+ * validating the result is inside the storage root.
66+ *
67+ * Supports relative paths (preferred) and absolute paths (legacy).
68+ * TODO: remove absolute path support once all callers pass relative paths
69+ *
70+ * @param {String } [targetDir] absolute or relative directory
71+ * @param {String } [fileName] file name to normalize and append
72+ * @returns {String } resolved absolute path inside storagePath
73+ */
74+ _resolveAndValidateStoragePath ( targetDir , fileName ) {
75+ const resolvedRoot = path . resolve ( this . storagePath ) ;
76+
77+ // Resolve targetDir: if already inside storage root use as-is, otherwise treat as relative
78+ let resolvedBase ;
79+ if ( targetDir ) {
80+ const resolvedTargetDir = path . resolve ( targetDir ) ;
81+ const relToRoot = path . relative ( resolvedRoot , resolvedTargetDir ) ;
82+ if ( relToRoot === '' || ( ! relToRoot . startsWith ( '..' ) && ! path . isAbsolute ( relToRoot ) ) ) {
83+ resolvedBase = resolvedTargetDir ;
84+ } else {
85+ resolvedBase = path . resolve ( this . storagePath , targetDir ) ;
86+ }
87+ } else {
88+ resolvedBase = resolvedRoot ;
89+ }
90+
91+ // If fileName provided, normalize and resolve
92+ let resolvedPath ;
93+ if ( fileName ) {
94+ const normalizedFileName = this . _normalizeStorageRelativePath ( fileName ) ;
95+ resolvedPath = path . resolve ( resolvedBase , normalizedFileName ) ;
96+ } else {
97+ resolvedPath = resolvedBase ;
98+ }
99+
100+ // Validate the resolved path is strictly inside the storage root (not equal to it)
101+ const relative = path . relative ( resolvedRoot , resolvedPath ) ;
102+ if ( relative === '' || relative === '..' || relative . startsWith ( '..' + path . sep ) || path . isAbsolute ( relative ) ) {
103+ throw new errors . IncorrectUsageError ( {
104+ message : tpl ( messages . invalidPathParameter , { path : fileName || targetDir || '' } )
105+ } ) ;
106+ }
107+
108+ return resolvedPath ;
109+ }
110+
41111 /**
42112 * Saves the file to storage (the file system)
43113 * - returns a promise which ultimately returns the full url to the uploaded file
@@ -49,13 +119,9 @@ class LocalStorageBase extends StorageBase {
49119 async save ( file , targetDir ) {
50120 let targetFilename ;
51121
52- // Supports relative paths (preferred) and absolute paths (legacy)
53- // TODO: remove absolute path support once all callers pass relative paths
54- if ( targetDir && ! targetDir . startsWith ( this . storagePath ) ) {
55- targetDir = path . join ( this . storagePath , targetDir ) ;
56- }
57-
58- targetDir = targetDir || this . getTargetDir ( this . storagePath ) ;
122+ targetDir = targetDir
123+ ? this . _resolveAndValidateStoragePath ( targetDir )
124+ : this . getTargetDir ( this . storagePath ) ;
59125
60126 const filename = await this . getUniqueFileName ( file , targetDir ) ;
61127
@@ -91,7 +157,7 @@ class LocalStorageBase extends StorageBase {
91157 * @returns {Promise<String> } a URL to retrieve the data
92158 */
93159 async saveRaw ( buffer , targetPath ) {
94- const storagePath = path . join ( this . storagePath , targetPath ) ;
160+ const storagePath = path . join ( this . storagePath , this . _normalizeStorageRelativePath ( targetPath ) ) ;
95161 const targetDir = path . dirname ( storagePath ) ;
96162
97163 await fs . mkdirs ( targetDir ) ;
@@ -131,31 +197,34 @@ class LocalStorageBase extends StorageBase {
131197 } ) ;
132198 }
133199
134- const normalized = path . posix . normalize ( relativePath . replace ( / ^ \/ / , '' ) ) ;
135- if ( normalized . startsWith ( '..' ) ) {
200+ try {
201+ return this . _normalizeStorageRelativePath ( relativePath ) ;
202+ } catch ( err ) {
136203 throw new errors . IncorrectUsageError ( {
137204 message : tpl ( messages . invalidUrlParameter , { url} )
138205 } ) ;
139206 }
140-
141- return normalized ;
142207 }
143208
144- exists ( fileName , targetDir ) {
145- // Supports relative paths (preferred) and absolute paths (legacy)
146- // TODO: remove absolute path support once all callers pass relative paths
147- if ( targetDir && ! targetDir . startsWith ( this . storagePath ) ) {
148- targetDir = path . join ( this . storagePath , targetDir ) ;
149- }
150- const filePath = path . join ( targetDir || this . storagePath , fileName ) ;
209+ async exists ( fileName , targetDir ) {
210+ let filePath ;
151211
152- return fs . stat ( filePath )
153- . then ( ( ) => {
154- return true ;
155- } )
156- . catch ( ( ) => {
212+ try {
213+ filePath = this . _resolveAndValidateStoragePath ( targetDir , fileName ) ;
214+ } catch ( err ) {
215+ if ( err instanceof errors . IncorrectUsageError ) {
157216 return false ;
158- } ) ;
217+ }
218+
219+ throw err ;
220+ }
221+
222+ try {
223+ await fs . stat ( filePath ) ;
224+ return true ;
225+ } catch {
226+ return false ;
227+ }
159228 }
160229
161230 /**
@@ -210,12 +279,7 @@ class LocalStorageBase extends StorageBase {
210279 * @returns {Promise.<*> }
211280 */
212281 async delete ( fileName , targetDir ) {
213- // Supports relative paths (preferred) and absolute paths (legacy)
214- // TODO: remove absolute path support once all callers pass relative paths
215- if ( targetDir && ! targetDir . startsWith ( this . storagePath ) ) {
216- targetDir = path . join ( this . storagePath , targetDir ) ;
217- }
218- const filePath = path . join ( targetDir || this . storagePath , fileName ) ;
282+ const filePath = this . _resolveAndValidateStoragePath ( targetDir , fileName ) ;
219283 return await fs . remove ( filePath ) ;
220284 }
221285
@@ -225,41 +289,35 @@ class LocalStorageBase extends StorageBase {
225289 *
226290 * @param options
227291 */
228- read ( options ) {
292+ async read ( options ) {
229293 options = options || { } ;
230294
231- // remove trailing slashes
232- options . path = ( options . path || '' ) . replace ( / \/ $ | \\ $ / , '' ) ;
295+ const normalizedPath = this . _normalizeStorageRelativePath ( options . path ) ;
296+ const targetPath = path . join ( this . storagePath , normalizedPath ) ;
233297
234- const targetPath = path . join ( this . storagePath , options . path ) ;
235-
236- return new Promise ( ( resolve , reject ) => {
237- fs . readFile ( targetPath , ( err , bytes ) => {
238- if ( err ) {
239- if ( err . code === 'ENOENT' || err . code === 'ENOTDIR' ) {
240- return reject ( new errors . NotFoundError ( {
241- err : err ,
242- message : tpl ( this . errorMessages . notFoundWithRef , { file : options . path } )
243- } ) ) ;
244- }
245-
246- if ( err . code === 'ENAMETOOLONG' ) {
247- return reject ( new errors . BadRequestError ( { err : err } ) ) ;
248- }
298+ try {
299+ return await fs . readFile ( targetPath ) ;
300+ } catch ( err ) {
301+ if ( err . code === 'ENOENT' || err . code === 'ENOTDIR' ) {
302+ throw new errors . NotFoundError ( {
303+ err : err ,
304+ message : tpl ( this . errorMessages . notFoundWithRef , { file : options . path } )
305+ } ) ;
306+ }
249307
250- if ( err . code === 'EACCES ' ) {
251- return reject ( new errors . NoPermissionError ( { err : err } ) ) ;
252- }
308+ if ( err . code === 'ENAMETOOLONG ' ) {
309+ throw new errors . BadRequestError ( { err : err } ) ;
310+ }
253311
254- return reject ( new errors . InternalServerError ( {
255- err : err ,
256- message : tpl ( this . errorMessages . cannotRead , { file : options . path } )
257- } ) ) ;
258- }
312+ if ( err . code === 'EACCES' ) {
313+ throw new errors . NoPermissionError ( { err : err } ) ;
314+ }
259315
260- resolve ( bytes ) ;
316+ throw new errors . InternalServerError ( {
317+ err : err ,
318+ message : tpl ( this . errorMessages . cannotRead , { file : options . path } )
261319 } ) ;
262- } ) ;
320+ }
263321 }
264322}
265323
0 commit comments