Skip to content

Commit f7389fe

Browse files
authored
fix: validate path traversal (#1463)
* fix: validate path traversal * chore: add more test cases * fix: directly copy path validator code over * remove dependency * simplify
1 parent e5b2562 commit f7389fe

File tree

3 files changed

+91
-14
lines changed

3 files changed

+91
-14
lines changed

src/services/db/GitFileSystemService.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
SimpleGit,
99
DefaultLogFields,
1010
LogResult,
11-
ListLogLine,
1211
} from "simple-git"
1312

1413
import logger from "@logger/logger"
@@ -43,6 +42,7 @@ import type {
4342
import type { IsomerCommitMessage } from "@root/types/github"
4443
import { ALLOWED_FILE_EXTENSIONS } from "@root/utils/file-upload-utils"
4544
import { getPaginatedDirectoryContents } from "@root/utils/files"
45+
import { isSafePath } from "@root/validators/validators"
4646

4747
// methods that do not need to be wrapped for instrumentation
4848
const METHOD_INSTRUMENTATION_BLACKLIST = [
@@ -366,22 +366,31 @@ export default class GitFileSystemService {
366366
const efsVolPath = isStaging
367367
? EFS_VOL_PATH_STAGING
368368
: EFS_VOL_PATH_STAGING_LITE
369-
return ResultAsync.fromPromise(
370-
fs.promises.stat(`${efsVolPath}/${repoName}/${filePath}`),
371-
(error) => {
372-
if (error instanceof Error && error.message.includes("ENOENT")) {
373-
return new NotFoundError("File/Directory does not exist")
374-
}
375369

376-
logger.error(`Error when reading ${filePath}: ${error}`)
370+
// Validate that the filePath is a valid relative path to prevent directory
371+
// traversal attacks
372+
const repoBaseDirectory = `${efsVolPath}/${repoName}`
373+
const fullFilePath = path.join(repoBaseDirectory, filePath)
374+
const isSafe = isSafePath(fullFilePath, repoBaseDirectory)
377375

378-
if (error instanceof Error) {
379-
return new GitFileSystemError("Unable to read file/directory")
380-
}
376+
if (!isSafe) {
377+
logger.error(`Invalid file path: ${filePath} for repo: ${repoName}`)
378+
return errAsync(new BadRequestError("Invalid file path"))
379+
}
381380

382-
return new GitFileSystemError("An unknown error occurred")
381+
return ResultAsync.fromPromise(fs.promises.stat(fullFilePath), (error) => {
382+
if (error instanceof Error && error.message.includes("ENOENT")) {
383+
return new NotFoundError("File/Directory does not exist")
383384
}
384-
)
385+
386+
logger.error(`Error when reading ${filePath}: ${error}`)
387+
388+
if (error instanceof Error) {
389+
return new GitFileSystemError("Unable to read file/directory")
390+
}
391+
392+
return new GitFileSystemError("An unknown error occurred")
393+
})
385394
}
386395

387396
/**

src/services/db/__tests__/GitFileSystemService.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs, { Stats } from "fs"
22

33
import mockFs from "mock-fs"
4-
import { errAsync, okAsync } from "neverthrow"
4+
import { okAsync } from "neverthrow"
55
import { GitError, SimpleGit } from "simple-git"
66

77
import config from "@config/config"
@@ -720,6 +720,46 @@ describe("GitFileSystemService", () => {
720720
expect(result._unsafeUnwrap().isDirectory()).toBeTrue()
721721
})
722722

723+
it("should return a BadRequestError if the path goes above the repo root", async () => {
724+
const result = await GitFileSystemService.getFilePathStats(
725+
"fake-repo",
726+
"../some-file",
727+
DEFAULT_BRANCH === "staging"
728+
)
729+
730+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
731+
})
732+
733+
it("should return a BadRequestError if the path contains glob expressions", async () => {
734+
const result = await GitFileSystemService.getFilePathStats(
735+
"fake-repo",
736+
"{../,a}/some-file",
737+
DEFAULT_BRANCH === "staging"
738+
)
739+
740+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
741+
})
742+
743+
it('should return a BadRequestError if path contains "%2f"', async () => {
744+
const result = await GitFileSystemService.getFilePathStats(
745+
"fake-repo",
746+
"..%2ffake-file",
747+
DEFAULT_BRANCH === "staging"
748+
)
749+
750+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
751+
})
752+
753+
it("should return a BadRequestError if path contains backslashes", async () => {
754+
const result = await GitFileSystemService.getFilePathStats(
755+
"fake-repo",
756+
"..\\fake-file",
757+
DEFAULT_BRANCH === "staging"
758+
)
759+
760+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
761+
})
762+
723763
it("should return a NotFoundError for a non-existent path", async () => {
724764
const result = await GitFileSystemService.getFilePathStats(
725765
"fake-repo",

src/validators/validators.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const path = require("path")
2+
13
const specialCharactersRegexTest = /[~%^*_+\-./\\`;~{}[\]"<>]/
24
const jekyllFirstCharacterRegexTest = /^[._#~]/
35
const dateRegexTest = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/
@@ -33,9 +35,35 @@ const isMediaPathValid = ({ path, isFile = false }) => {
3335

3436
const isPasswordValid = (password) => passwordRegexTest.test(password)
3537

38+
const isSafePath = (absPath, basePath) => {
39+
// check for poison null bytes
40+
if (absPath.indexOf("\0") !== -1) {
41+
return false
42+
}
43+
// check for backslashes
44+
if (absPath.indexOf("\\") !== -1) {
45+
return false
46+
}
47+
48+
// check for dot segments, even if they don't normalize to anything
49+
if (absPath.includes("..")) {
50+
return false
51+
}
52+
53+
// check if the normalized path is within the provided 'safe' base path
54+
if (path.resolve(basePath, path.relative(basePath, absPath)) !== absPath) {
55+
return false
56+
}
57+
if (absPath.indexOf(basePath) !== 0) {
58+
return false
59+
}
60+
return true
61+
}
62+
3663
module.exports = {
3764
hasSpecialCharInTitle,
3865
isDateValid,
3966
isMediaPathValid,
4067
isPasswordValid,
68+
isSafePath,
4169
}

0 commit comments

Comments
 (0)