Skip to content

Commit ee7afbb

Browse files
committed
fix: validate path traversal
1 parent e5b2562 commit ee7afbb

File tree

4 files changed

+101
-38
lines changed

4 files changed

+101
-38
lines changed

package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@octokit/rest": "^20.1.1",
4242
"@opengovsg/formsg-sdk": "^0.11.0",
4343
"@opengovsg/sgid-client": "^2.0.0",
44+
"@opengovsg/starter-kitty-validators": "^1.2.11",
4445
"@slack/bolt": "^3.19.0",
4546
"auto-bind": "^4.0.0",
4647
"aws-lambda": "^1.0.7",

src/services/db/GitFileSystemService.ts

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import fs from "fs"
22
import path from "path"
33

4+
import { createPathSchema } from "@opengovsg/starter-kitty-validators"
45
import { err, errAsync, ok, okAsync, Result, ResultAsync } from "neverthrow"
56
import {
67
CleanOptions,
78
GitError,
89
SimpleGit,
910
DefaultLogFields,
1011
LogResult,
11-
ListLogLine,
1212
} from "simple-git"
1313

1414
import logger from "@logger/logger"
@@ -366,22 +366,32 @@ 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.resolve(repoBaseDirectory, filePath)
374+
const pathSchema = createPathSchema({ basePath: repoBaseDirectory })
375+
const parsedPathResult = pathSchema.safeParse(fullFilePath)
377376

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

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

387397
/**
@@ -985,35 +995,39 @@ export default class GitFileSystemService {
985995
encoding: "utf-8" | "base64" = "utf-8"
986996
): ResultAsync<GitFile, GitFileSystemError | NotFoundError> {
987997
const defaultEfsVolPath = EFS_VOL_PATH_STAGING
988-
return ResultAsync.combine([
989-
ResultAsync.fromPromise(
990-
fs.promises.readFile(
991-
`${defaultEfsVolPath}/${repoName}/${filePath}`,
992-
encoding
993-
),
994-
(error) => {
995-
if (error instanceof Error && error.message.includes("ENOENT")) {
996-
return new NotFoundError("File does not exist")
997-
}
998+
return this.getFilePathStats(repoName, filePath, true)
999+
.andThen(() =>
1000+
ResultAsync.combine([
1001+
ResultAsync.fromPromise(
1002+
fs.promises.readFile(
1003+
`${defaultEfsVolPath}/${repoName}/${filePath}`,
1004+
encoding
1005+
),
1006+
(error) => {
1007+
if (error instanceof Error && error.message.includes("ENOENT")) {
1008+
return new NotFoundError("File does not exist")
1009+
}
9981010

999-
logger.error(`Error when reading ${filePath}: ${error}`)
1011+
logger.error(`Error when reading ${filePath}: ${error}`)
10001012

1001-
if (error instanceof Error) {
1002-
return new GitFileSystemError("Unable to read file")
1003-
}
1013+
if (error instanceof Error) {
1014+
return new GitFileSystemError("Unable to read file")
1015+
}
10041016

1005-
return new GitFileSystemError("An unknown error occurred")
1017+
return new GitFileSystemError("An unknown error occurred")
1018+
}
1019+
),
1020+
this.getGitBlobHash(repoName, filePath, true),
1021+
])
1022+
)
1023+
.map((contentAndHash) => {
1024+
const [content, sha] = contentAndHash
1025+
const result: GitFile = {
1026+
content,
1027+
sha,
10061028
}
1007-
),
1008-
this.getGitBlobHash(repoName, filePath, true),
1009-
]).map((contentAndHash) => {
1010-
const [content, sha] = contentAndHash
1011-
const result: GitFile = {
1012-
content,
1013-
sha,
1014-
}
1015-
return result
1016-
})
1029+
return result
1030+
})
10171031
}
10181032

10191033
getFileExtension(fileName: string): Result<string, MediaTypeError> {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,16 @@ 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+
723733
it("should return a NotFoundError for a non-existent path", async () => {
724734
const result = await GitFileSystemService.getFilePathStats(
725735
"fake-repo",

0 commit comments

Comments
 (0)