fix(security): confine file resolution to project roots
Block traversal, out-of-root absolute path, and symlink escapes for @file references, file:// URIs, and config skill file loading while logging rejected attempts. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
function toCanonicalPath(pathToNormalize: string): string {
|
||||
const resolvedPath = resolve(pathToNormalize)
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
try {
|
||||
return normalize(realpathSync.native(resolvedPath))
|
||||
} catch {
|
||||
return normalize(resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(resolvedPath)
|
||||
const canonicalParentDirectory = existsSync(parentDirectory)
|
||||
? realpathSync.native(parentDirectory)
|
||||
: parentDirectory
|
||||
|
||||
return normalize(join(canonicalParentDirectory, basename(resolvedPath)))
|
||||
}
|
||||
|
||||
export function containsPath(rootPath: string, candidatePath: string): boolean {
|
||||
const canonicalRootPath = toCanonicalPath(rootPath)
|
||||
const canonicalCandidatePath = toCanonicalPath(candidatePath)
|
||||
const relativePath = relative(canonicalRootPath, canonicalCandidatePath)
|
||||
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
export function isWithinProject(candidatePath: string, projectRoot: string): boolean {
|
||||
return containsPath(projectRoot, candidatePath)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
||||
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { resolveFileReferencesInText } from "./file-reference-resolver"
|
||||
|
||||
describe("resolveFileReferencesInText", () => {
|
||||
const fixtureRoot = join(tmpdir(), `file-reference-resolver-${Date.now()}`)
|
||||
const workspaceDir = join(fixtureRoot, "workspace")
|
||||
const notesDir = join(workspaceDir, "notes")
|
||||
const allowedFilePath = join(notesDir, "allowed.txt")
|
||||
const linkedSecretPath = join(notesDir, "linked-secret.txt")
|
||||
const outsideFilePath = join(fixtureRoot, "secret.txt")
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(notesDir, { recursive: true })
|
||||
writeFileSync(allowedFilePath, "allowed-content", "utf8")
|
||||
writeFileSync(outsideFilePath, "secret-content", "utf8")
|
||||
symlinkSync(outsideFilePath, linkedSecretPath)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("resolves file references within cwd", async () => {
|
||||
//#given
|
||||
const input = "Read @notes/allowed.txt before continuing"
|
||||
|
||||
//#when
|
||||
const resolved = await resolveFileReferencesInText(input, workspaceDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("allowed-content")
|
||||
})
|
||||
|
||||
test("rejects traversal references that escape cwd", async () => {
|
||||
//#given
|
||||
const input = "Read @../secret.txt before continuing"
|
||||
|
||||
//#when
|
||||
const resolved = await resolveFileReferencesInText(input, workspaceDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[path rejected:")
|
||||
expect(resolved).not.toContain("secret-content")
|
||||
})
|
||||
|
||||
test("rejects absolute references outside cwd", async () => {
|
||||
//#given
|
||||
const input = `Read @${outsideFilePath} before continuing`
|
||||
|
||||
//#when
|
||||
const resolved = await resolveFileReferencesInText(input, workspaceDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[path rejected:")
|
||||
expect(resolved).not.toContain("secret-content")
|
||||
})
|
||||
|
||||
test("rejects symlink references that escape cwd", async () => {
|
||||
//#given
|
||||
const input = "Read @notes/linked-secret.txt before continuing"
|
||||
|
||||
//#when
|
||||
const resolved = await resolveFileReferencesInText(input, workspaceDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[path rejected:")
|
||||
expect(resolved).not.toContain("secret-content")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { existsSync, readFileSync, statSync } from "fs"
|
||||
import { join, isAbsolute } from "path"
|
||||
import { isAbsolute, resolve } from "path"
|
||||
import { isWithinProject } from "./contains-path"
|
||||
import { log } from "./logger"
|
||||
|
||||
interface FileMatch {
|
||||
fullMatch: string
|
||||
@@ -30,9 +32,10 @@ function findFileReferences(text: string): FileMatch[] {
|
||||
|
||||
function resolveFilePath(filePath: string, cwd: string): string {
|
||||
if (isAbsolute(filePath)) {
|
||||
return filePath
|
||||
return resolve(filePath)
|
||||
}
|
||||
return join(cwd, filePath)
|
||||
|
||||
return resolve(cwd, filePath)
|
||||
}
|
||||
|
||||
function readFileContent(resolvedPath: string): string {
|
||||
@@ -68,6 +71,17 @@ export async function resolveFileReferencesInText(
|
||||
|
||||
for (const match of matches) {
|
||||
const resolvedPath = resolveFilePath(match.filePath, cwd)
|
||||
|
||||
if (!isWithinProject(resolvedPath, cwd)) {
|
||||
log("[file-reference-resolver] Rejected file reference outside project root", {
|
||||
filePath: match.filePath,
|
||||
resolvedPath,
|
||||
projectRoot: cwd,
|
||||
})
|
||||
replacements.set(match.fullMatch, `[path rejected: ${match.filePath}]`)
|
||||
continue
|
||||
}
|
||||
|
||||
const content = readFileContent(resolvedPath)
|
||||
replacements.set(match.fullMatch, content)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./frontmatter"
|
||||
export * from "./command-executor"
|
||||
export * from "./contains-path"
|
||||
export * from "./file-reference-resolver"
|
||||
export * from "./model-sanitizer"
|
||||
export * from "./logger"
|
||||
|
||||
Reference in New Issue
Block a user