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:
@@ -1,5 +1,5 @@
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
@@ -24,6 +24,8 @@ describe("resolvePromptAppend", () => {
|
||||
const relativeFilePath = join(configDir, "relative.txt")
|
||||
const spacedFilePath = join(fixtureRoot, "with space.txt")
|
||||
const homeFilePath = join(homeFixtureDir, "home.txt")
|
||||
const escapedFilePath = join(fixtureRoot, "escaped.txt")
|
||||
const linkedAbsolutePath = join(configDir, "linked-absolute.txt")
|
||||
|
||||
beforeAll(async () => {
|
||||
mockedHomeDir = homeFixtureRoot
|
||||
@@ -35,6 +37,8 @@ describe("resolvePromptAppend", () => {
|
||||
writeFileSync(relativeFilePath, "relative-content", "utf8")
|
||||
writeFileSync(spacedFilePath, "encoded-content", "utf8")
|
||||
writeFileSync(homeFilePath, "home-content", "utf8")
|
||||
writeFileSync(escapedFilePath, "escaped-content", "utf8")
|
||||
symlinkSync(absoluteFilePath, linkedAbsolutePath)
|
||||
|
||||
moduleImportCounter += 1
|
||||
;({ resolvePromptAppend } = await import(`./resolve-file-uri?test=${moduleImportCounter}`))
|
||||
@@ -61,7 +65,7 @@ describe("resolvePromptAppend", () => {
|
||||
const input = `file://${absoluteFilePath}`
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input)
|
||||
const resolved = resolvePromptAppend(input, fixtureRoot)
|
||||
|
||||
//#then
|
||||
expect(resolved).toBe("absolute-content")
|
||||
@@ -83,7 +87,7 @@ describe("resolvePromptAppend", () => {
|
||||
const input = "file://~/fixture-home/home.txt"
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input)
|
||||
const resolved = resolvePromptAppend(input, homeFixtureRoot)
|
||||
|
||||
//#then
|
||||
expect(resolved).toBe("home-content")
|
||||
@@ -94,7 +98,7 @@ describe("resolvePromptAppend", () => {
|
||||
const input = `file://${encodeURIComponent(spacedFilePath)}`
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input)
|
||||
const resolved = resolvePromptAppend(input, fixtureRoot)
|
||||
|
||||
//#then
|
||||
expect(resolved).toBe("encoded-content")
|
||||
@@ -113,12 +117,48 @@ describe("resolvePromptAppend", () => {
|
||||
|
||||
test("returns warning when file does not exist", () => {
|
||||
//#given
|
||||
const input = "file:///path/does/not/exist.txt"
|
||||
const input = "file://./missing.txt"
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input)
|
||||
const resolved = resolvePromptAppend(input, configDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[WARNING: Could not resolve file URI")
|
||||
})
|
||||
|
||||
test("rejects absolute file URI outside configDir", () => {
|
||||
//#given
|
||||
const input = `file://${absoluteFilePath}`
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input, configDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[WARNING: Path rejected:")
|
||||
expect(resolved).not.toContain("absolute-content")
|
||||
})
|
||||
|
||||
test("rejects traversal file URI that escapes configDir", () => {
|
||||
//#given
|
||||
const input = "file://../escaped.txt"
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input, configDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[WARNING: Path rejected:")
|
||||
expect(resolved).not.toContain("escaped-content")
|
||||
})
|
||||
|
||||
test("rejects symlink file URI that escapes configDir", () => {
|
||||
//#given
|
||||
const input = "file://./linked-absolute.txt"
|
||||
|
||||
//#when
|
||||
const resolved = resolvePromptAppend(input, configDir)
|
||||
|
||||
//#then
|
||||
expect(resolved).toContain("[WARNING: Path rejected:")
|
||||
expect(resolved).not.toContain("absolute-content")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import { isWithinProject } from "../../shared/contains-path"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function resolvePromptAppend(promptAppend: string, configDir?: string): string {
|
||||
if (!promptAppend.startsWith("file://")) return promptAppend
|
||||
@@ -18,6 +20,16 @@ export function resolvePromptAppend(promptAppend: string, configDir?: string): s
|
||||
return `[WARNING: Malformed file URI (invalid percent-encoding): ${promptAppend}]`
|
||||
}
|
||||
|
||||
const projectRoot = configDir ?? process.cwd()
|
||||
if (!isWithinProject(filePath, projectRoot)) {
|
||||
log("[resolve-file-uri] Rejected file URI outside project root", {
|
||||
promptAppend,
|
||||
filePath,
|
||||
projectRoot,
|
||||
})
|
||||
return `[WARNING: Path rejected: ${promptAppend}]`
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return `[WARNING: Could not resolve file URI: ${promptAppend}]`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user