Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d

Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

#### [v0.94.0](https://github.com/isomerpages/isomercms-backend/compare/v0.93.0...v0.94.0)

- fix: validate path traversal [`#1463`](https://github.com/isomerpages/isomercms-backend/pull/1463)
- chore: bump version to v0.93.0 [`#1462`](https://github.com/isomerpages/isomercms-backend/pull/1462)

#### [v0.93.0](https://github.com/isomerpages/isomercms-backend/compare/v0.92.0...v0.93.0)

> 22 May 2025

- fix: comment logic [`#1460`](https://github.com/isomerpages/isomercms-backend/pull/1460)
- backport v0.92.0 [`#1459`](https://github.com/isomerpages/isomercms-backend/pull/1459)
- chore: bump version to v0.93.0 [`5b9fd99`](https://github.com/isomerpages/isomercms-backend/commit/5b9fd9923b437867b00e4cc8f82c44ab70f1e3e9)

#### [v0.92.0](https://github.com/isomerpages/isomercms-backend/compare/v0.91.0...v0.92.0)

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "isomercms",
"version": "0.93.0",
"version": "0.94.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.build.json",
Expand Down
35 changes: 22 additions & 13 deletions src/services/db/GitFileSystemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
SimpleGit,
DefaultLogFields,
LogResult,
ListLogLine,
} from "simple-git"

import logger from "@logger/logger"
Expand Down Expand Up @@ -43,6 +42,7 @@ import type {
import type { IsomerCommitMessage } from "@root/types/github"
import { ALLOWED_FILE_EXTENSIONS } from "@root/utils/file-upload-utils"
import { getPaginatedDirectoryContents } from "@root/utils/files"
import { isSafePath } from "@root/validators/validators"

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

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

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

return new GitFileSystemError("An unknown error occurred")
return ResultAsync.fromPromise(fs.promises.stat(fullFilePath), (error) => {
if (error instanceof Error && error.message.includes("ENOENT")) {
return new NotFoundError("File/Directory does not exist")
}
)

logger.error(`Error when reading ${filePath}: ${error}`)

if (error instanceof Error) {
return new GitFileSystemError("Unable to read file/directory")
}

return new GitFileSystemError("An unknown error occurred")
})
}

/**
Expand Down
42 changes: 41 additions & 1 deletion src/services/db/__tests__/GitFileSystemService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs, { Stats } from "fs"

import mockFs from "mock-fs"
import { errAsync, okAsync } from "neverthrow"
import { okAsync } from "neverthrow"
import { GitError, SimpleGit } from "simple-git"

import config from "@config/config"
Expand Down Expand Up @@ -720,6 +720,46 @@ describe("GitFileSystemService", () => {
expect(result._unsafeUnwrap().isDirectory()).toBeTrue()
})

it("should return a BadRequestError if the path goes above the repo root", async () => {
const result = await GitFileSystemService.getFilePathStats(
"fake-repo",
"../some-file",
DEFAULT_BRANCH === "staging"
)

expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
})

it("should return a BadRequestError if the path contains glob expressions", async () => {
const result = await GitFileSystemService.getFilePathStats(
"fake-repo",
"{../,a}/some-file",
DEFAULT_BRANCH === "staging"
)

expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
})

it('should return a BadRequestError if path contains "%2f"', async () => {
const result = await GitFileSystemService.getFilePathStats(
"fake-repo",
"..%2ffake-file",
DEFAULT_BRANCH === "staging"
)

expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
})

it("should return a BadRequestError if path contains backslashes", async () => {
const result = await GitFileSystemService.getFilePathStats(
"fake-repo",
"..\\fake-file",
DEFAULT_BRANCH === "staging"
)

expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError)
})

it("should return a NotFoundError for a non-existent path", async () => {
const result = await GitFileSystemService.getFilePathStats(
"fake-repo",
Expand Down
28 changes: 28 additions & 0 deletions src/validators/validators.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const path = require("path")

const specialCharactersRegexTest = /[~%^*_+\-./\\`;~{}[\]"<>]/
const jekyllFirstCharacterRegexTest = /^[._#~]/
const dateRegexTest = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/
Expand Down Expand Up @@ -33,9 +35,35 @@ const isMediaPathValid = ({ path, isFile = false }) => {

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

const isSafePath = (absPath, basePath) => {
// check for poison null bytes
if (absPath.indexOf("\0") !== -1) {
return false
}
// check for backslashes
if (absPath.indexOf("\\") !== -1) {
return false
}

// check for dot segments, even if they don't normalize to anything
if (absPath.includes("..")) {
return false
}

// check if the normalized path is within the provided 'safe' base path
if (path.resolve(basePath, path.relative(basePath, absPath)) !== absPath) {
return false
}
if (absPath.indexOf(basePath) !== 0) {
return false
}
return true
}

module.exports = {
hasSpecialCharInTitle,
isDateValid,
isMediaPathValid,
isPasswordValid,
isSafePath,
}
Loading