Skip to content

Commit 40ae729

Browse files
authored
fix: prevent access to files outside of storage path in file backend (#818)
1 parent d0ce99d commit 40ae729

File tree

1 file changed

+34
-18
lines changed

1 file changed

+34
-18
lines changed

src/storage/backend/file.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export class FileBackend implements StorageBackendAdapter {
8484
headers?: BrowserCacheHeaders
8585
): Promise<ObjectResponse> {
8686
// 'Range: bytes=#######-######
87-
const file = path.resolve(this.filePath, withOptionalVersion(`${bucketName}/${key}`, version))
87+
const file = this.resolveSecurePath(withOptionalVersion(`${bucketName}/${key}`, version))
8888
const data = await fs.stat(file)
8989
const eTag = await this.etag(file, data)
9090
const fileSize = data.size
@@ -185,7 +185,7 @@ export class FileBackend implements StorageBackendAdapter {
185185
cacheControl: string
186186
): Promise<ObjectMetadata> {
187187
try {
188-
const file = path.resolve(this.filePath, withOptionalVersion(`${bucketName}/${key}`, version))
188+
const file = this.resolveSecurePath(withOptionalVersion(`${bucketName}/${key}`, version))
189189
await fs.ensureFile(file)
190190
const destFile = fs.createWriteStream(file)
191191
await pipeline(body, destFile)
@@ -214,7 +214,7 @@ export class FileBackend implements StorageBackendAdapter {
214214
*/
215215
async deleteObject(bucket: string, key: string, version: string | undefined): Promise<void> {
216216
try {
217-
const file = path.resolve(this.filePath, withOptionalVersion(`${bucket}/${key}`, version))
217+
const file = this.resolveSecurePath(withOptionalVersion(`${bucket}/${key}`, version))
218218
await fs.remove(file)
219219

220220
// Clean up empty parent directories
@@ -246,9 +246,8 @@ export class FileBackend implements StorageBackendAdapter {
246246
destinationVersion: string,
247247
metadata: { cacheControl?: string; contentType?: string }
248248
): Promise<Pick<ObjectMetadata, 'httpStatusCode' | 'eTag' | 'lastModified'>> {
249-
const srcFile = path.resolve(this.filePath, withOptionalVersion(`${bucket}/${source}`, version))
250-
const destFile = path.resolve(
251-
this.filePath,
249+
const srcFile = this.resolveSecurePath(withOptionalVersion(`${bucket}/${source}`, version))
250+
const destFile = this.resolveSecurePath(
252251
withOptionalVersion(`${bucket}/${destination}`, destinationVersion)
253252
)
254253

@@ -275,7 +274,7 @@ export class FileBackend implements StorageBackendAdapter {
275274
*/
276275
async deleteObjects(bucket: string, prefixes: string[]): Promise<void> {
277276
const promises = prefixes.map((prefix) => {
278-
return fs.rm(path.resolve(this.filePath, bucket, prefix))
277+
return fs.rm(this.resolveSecurePath(`${bucket}/${prefix}`))
279278
})
280279
const results = await Promise.allSettled(promises)
281280

@@ -290,7 +289,7 @@ export class FileBackend implements StorageBackendAdapter {
290289
throw result.reason
291290
} else {
292291
// Add parent directory of successfully deleted file
293-
const filePath = path.resolve(this.filePath, bucket, prefixes[index])
292+
const filePath = this.resolveSecurePath(`${bucket}/${prefixes[index]}`)
294293
parentDirs.add(path.dirname(filePath))
295294
}
296295
})
@@ -299,7 +298,7 @@ export class FileBackend implements StorageBackendAdapter {
299298
for (const dir of parentDirs) {
300299
try {
301300
await this.cleanupEmptyDirectories(dir)
302-
} catch (e) {
301+
} catch {
303302
// Ignore cleanup errors to not affect the main deletion operation
304303
}
305304
}
@@ -316,7 +315,7 @@ export class FileBackend implements StorageBackendAdapter {
316315
key: string,
317316
version: string | undefined
318317
): Promise<ObjectMetadata> {
319-
const file = path.join(this.filePath, withOptionalVersion(`${bucket}/${key}`, version))
318+
const file = this.resolveSecurePath(withOptionalVersion(`${bucket}/${key}`, version))
320319

321320
const data = await fs.stat(file)
322321
const { cacheControl, contentType } = await this.getFileMetadata(file)
@@ -474,7 +473,7 @@ export class FileBackend implements StorageBackendAdapter {
474473
// Clean up empty parent directories
475474
try {
476475
await this.cleanupEmptyDirectories(path.dirname(multiPartFolder))
477-
} catch (e) {
476+
} catch {
478477
// Ignore cleanup errors
479478
}
480479
}
@@ -498,10 +497,8 @@ export class FileBackend implements StorageBackendAdapter {
498497
)
499498

500499
const partFilePath = path.join(multiPartFolder, `part-${PartNumber}`)
501-
const sourceFilePath = path.join(
502-
this.filePath,
503-
storageS3Bucket,
504-
withOptionalVersion(sourceKey, sourceVersion)
500+
const sourceFilePath = this.resolveSecurePath(
501+
`${storageS3Bucket}/${withOptionalVersion(sourceKey, sourceVersion)}`
505502
)
506503

507504
const platform = process.platform == 'darwin' ? 'darwin' : 'linux'
@@ -532,7 +529,7 @@ export class FileBackend implements StorageBackendAdapter {
532529
* @param version
533530
*/
534531
async privateAssetUrl(bucket: string, key: string, version: string | undefined): Promise<string> {
535-
return 'local:///' + path.join(this.filePath, withOptionalVersion(`${bucket}/${key}`, version))
532+
return 'local:///' + this.resolveSecurePath(withOptionalVersion(`${bucket}/${key}`, version))
536533
}
537534

538535
async setFileMetadata(file: string, { contentType, cacheControl }: FileMetadata) {
@@ -582,7 +579,7 @@ export class FileBackend implements StorageBackendAdapter {
582579
await directory.close()
583580

584581
return entry === null
585-
} catch (error) {
582+
} catch {
586583
return false
587584
}
588585
}
@@ -614,14 +611,33 @@ export class FileBackend implements StorageBackendAdapter {
614611
const parentDir = path.dirname(dirPath)
615612
await this.cleanupEmptyDirectories(parentDir)
616613
}
617-
} catch (e: any) {
614+
} catch {
618615
// Ignore errors during cleanup to not affect main operations
619616
// Could be permission issues, concurrent access, directory not empty due to race conditions, etc.
620617
// Optional: Log for debugging purposes (uncomment if needed)
621618
// console.debug('Directory cleanup failed:', dirPath, e.message)
622619
}
623620
}
624621

622+
/**
623+
* Securely resolves a path within the storage directory, preventing path traversal attacks
624+
* @param relativePath The relative path to resolve
625+
* @throws {StorageBackendError} If the resolved path escapes the storage directory
626+
*/
627+
private resolveSecurePath(relativePath: string): string {
628+
const resolvedPath = path.resolve(this.filePath, relativePath)
629+
const normalizedPath = path.normalize(resolvedPath)
630+
631+
// Ensure the resolved path is within the storage directory
632+
if (!normalizedPath.startsWith(this.filePath + path.sep) && normalizedPath !== this.filePath) {
633+
throw ERRORS.InvalidKey(
634+
`Path traversal detected: ${relativePath} resolves outside storage directory`
635+
)
636+
}
637+
638+
return normalizedPath
639+
}
640+
625641
private async etag(file: string, stats: fs.Stats): Promise<string> {
626642
if (this.etagAlgorithm === 'md5') {
627643
const checksum = await fileChecksum(file)

0 commit comments

Comments
 (0)