Skip to content

Commit 85e6efe

Browse files
authored
fix: path traversal in file backend (#883)
related to #818 Additionally, reject paths with segments (., ..), absolute paths, windows drive/UNC, containing null bytes. Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 734fea0 commit 85e6efe

File tree

2 files changed

+325
-36
lines changed

2 files changed

+325
-36
lines changed

src/storage/backend/file.ts

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ export class FileBackend implements StorageBackendAdapter {
202202
httpStatusCode: 200,
203203
}
204204
} catch (err: any) {
205+
if (err instanceof StorageBackendError) {
206+
throw err
207+
}
205208
throw StorageBackendError.fromError(err)
206209
}
207210
}
@@ -341,15 +344,18 @@ export class FileBackend implements StorageBackendAdapter {
341344
cacheControl: string
342345
): Promise<string | undefined> {
343346
const uploadId = randomUUID()
344-
const multiPartFolder = path.join(
345-
this.filePath,
346-
'multiparts',
347-
uploadId,
348-
bucketName,
349-
withOptionalVersion(key, version)
347+
const multiPartFolder = this.resolveSecurePath(
348+
path.join('multiparts', uploadId, bucketName, withOptionalVersion(key, version))
349+
)
350+
const multipartFile = this.resolveSecurePath(
351+
path.join(
352+
'multiparts',
353+
uploadId,
354+
bucketName,
355+
withOptionalVersion(key, version),
356+
'metadata.json'
357+
)
350358
)
351-
352-
const multipartFile = path.join(multiPartFolder, 'metadata.json')
353359
await fsExtra.ensureDir(multiPartFolder)
354360
await fsExtra.writeFile(multipartFile, JSON.stringify({ contentType, cacheControl }))
355361

@@ -364,16 +370,16 @@ export class FileBackend implements StorageBackendAdapter {
364370
partNumber: number,
365371
body: stream.Readable
366372
): Promise<{ ETag?: string }> {
367-
const multiPartFolder = path.join(
368-
this.filePath,
369-
'multiparts',
370-
uploadId,
371-
bucketName,
372-
withOptionalVersion(key, version)
373+
const partPath = this.resolveSecurePath(
374+
path.join(
375+
'multiparts',
376+
uploadId,
377+
bucketName,
378+
withOptionalVersion(key, version),
379+
`part-${partNumber}`
380+
)
373381
)
374382

375-
const partPath = path.join(multiPartFolder, `part-${partNumber}`)
376-
377383
const writeStream = fsExtra.createWriteStream(partPath)
378384

379385
await pipeline(body, writeStream)
@@ -399,16 +405,16 @@ export class FileBackend implements StorageBackendAdapter {
399405
version: string
400406
}
401407
> {
402-
const multiPartFolder = path.join(
403-
this.filePath,
404-
'multiparts',
405-
uploadId,
406-
bucketName,
407-
withOptionalVersion(key, version)
408-
)
409-
410408
const partsByEtags = parts.map(async (part) => {
411-
const partFilePath = path.join(multiPartFolder, `part-${part.PartNumber}`)
409+
const partFilePath = this.resolveSecurePath(
410+
path.join(
411+
'multiparts',
412+
uploadId,
413+
bucketName,
414+
withOptionalVersion(key, version),
415+
`part-${part.PartNumber}`
416+
)
417+
)
412418
const partExists = await fsExtra.pathExists(partFilePath)
413419

414420
if (partExists) {
@@ -432,7 +438,15 @@ export class FileBackend implements StorageBackendAdapter {
432438

433439
const multistream = new MultiStream(fileStreams)
434440
const metadataContent = await fsExtra.readFile(
435-
path.join(multiPartFolder, 'metadata.json'),
441+
this.resolveSecurePath(
442+
path.join(
443+
'multiparts',
444+
uploadId,
445+
bucketName,
446+
withOptionalVersion(key, version),
447+
'metadata.json'
448+
)
449+
),
436450
'utf-8'
437451
)
438452

@@ -447,7 +461,7 @@ export class FileBackend implements StorageBackendAdapter {
447461
metadata.cacheControl
448462
)
449463

450-
fsExtra.remove(path.join(this.filePath, 'multiparts', uploadId)).catch(() => {
464+
fsExtra.remove(this.resolveSecurePath(path.join('multiparts', uploadId))).catch(() => {
451465
// no-op
452466
})
453467

@@ -465,7 +479,7 @@ export class FileBackend implements StorageBackendAdapter {
465479
uploadId: string,
466480
version?: string
467481
): Promise<void> {
468-
const multiPartFolder = path.join(this.filePath, 'multiparts', uploadId)
482+
const multiPartFolder = this.resolveSecurePath(path.join('multiparts', uploadId))
469483

470484
await fsExtra.remove(multiPartFolder)
471485

@@ -487,15 +501,15 @@ export class FileBackend implements StorageBackendAdapter {
487501
sourceVersion?: string,
488502
rangeBytes?: { fromByte: number; toByte: number }
489503
): Promise<{ eTag?: string; lastModified?: Date }> {
490-
const multiPartFolder = path.join(
491-
this.filePath,
492-
'multiparts',
493-
UploadId,
494-
storageS3Bucket,
495-
withOptionalVersion(key, version)
504+
const partFilePath = this.resolveSecurePath(
505+
path.join(
506+
'multiparts',
507+
UploadId,
508+
storageS3Bucket,
509+
withOptionalVersion(key, version),
510+
`part-${PartNumber}`
511+
)
496512
)
497-
498-
const partFilePath = path.join(multiPartFolder, `part-${PartNumber}`)
499513
const sourceFilePath = this.resolveSecurePath(
500514
`${storageS3Bucket}/${withOptionalVersion(sourceKey, sourceVersion)}`
501515
)
@@ -624,6 +638,29 @@ export class FileBackend implements StorageBackendAdapter {
624638
* @throws {StorageBackendError} If the resolved path escapes the storage directory
625639
*/
626640
private resolveSecurePath(relativePath: string): string {
641+
if (relativePath.includes('\0')) {
642+
throw ERRORS.InvalidKey(`Invalid key: ${relativePath} contains null byte`)
643+
}
644+
645+
if (path.isAbsolute(relativePath)) {
646+
throw ERRORS.InvalidKey(`Invalid key: ${relativePath} must be a relative path`)
647+
}
648+
649+
const isWindowsDriveAbsolutePath = /^[a-zA-Z]:[\\/]/.test(relativePath)
650+
const isWindowsUncPath = /^\\\\[^\\/]+[\\/][^\\/]+/.test(relativePath)
651+
if (isWindowsDriveAbsolutePath || isWindowsUncPath) {
652+
throw ERRORS.InvalidKey(`Invalid key: ${relativePath} must not be an absolute Windows path`)
653+
}
654+
655+
const hasDotTraversalSegment = relativePath
656+
.split(/[\\/]+/)
657+
.filter(Boolean)
658+
.some((segment) => segment === '.' || segment === '..')
659+
660+
if (hasDotTraversalSegment) {
661+
throw ERRORS.InvalidKey(`Path traversal detected: ${relativePath} contains dot path segment`)
662+
}
663+
627664
const resolvedPath = path.resolve(this.filePath, relativePath)
628665
const normalizedPath = path.normalize(resolvedPath)
629666

0 commit comments

Comments
 (0)