fix(prompts-core): block prompt path traversal
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -8,4 +8,4 @@ export type {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
export { resolveVariant } from "./variant-resolver"
|
export { resolveVariant } from "./variant-resolver"
|
||||||
export type { ResolveVariantInput } from "./variant-resolver"
|
export type { ResolveVariantInput } from "./variant-resolver"
|
||||||
export { loadPrompt, PromptFileNotFoundError } from "./loader"
|
export { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { loadPrompt, PromptFileNotFoundError } from "./loader"
|
import { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||||
import type { PromptSource } from "./types"
|
import type { PromptSource } from "./types"
|
||||||
|
|
||||||
const fixtureSource: PromptSource = {
|
const fixtureSource: PromptSource = {
|
||||||
@@ -50,6 +50,22 @@ describe("loadPrompt", () => {
|
|||||||
expect(expectError(error).message).toContain("test-prompt/missing")
|
expect(expectError(error).message).toContain("test-prompt/missing")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given prompt name escapes source directory #then rejects path traversal", async () => {
|
||||||
|
const error = await captureError(() =>
|
||||||
|
loadPrompt({ source: fixtureSource, name: "../test-prompt", variant: "default" })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(PromptPathTraversalError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given variant escapes source directory #then rejects path traversal", async () => {
|
||||||
|
const error = await captureError(() =>
|
||||||
|
loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "../../outside" })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(PromptPathTraversalError)
|
||||||
|
})
|
||||||
|
|
||||||
test("#given runtime injection #then replaces placeholder in body", async () => {
|
test("#given runtime injection #then replaces placeholder in body", async () => {
|
||||||
const prompt = await loadPrompt({
|
const prompt = await loadPrompt({
|
||||||
source: fixtureSource,
|
source: fixtureSource,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { isAbsolute, relative, resolve } from "node:path"
|
||||||
import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types"
|
import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types"
|
||||||
|
|
||||||
export class PromptFileNotFoundError extends Error {
|
export class PromptFileNotFoundError extends Error {
|
||||||
@@ -16,10 +16,21 @@ export class PromptFileNotFoundError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PromptPathTraversalError extends Error {
|
||||||
|
readonly name = "PromptPathTraversalError"
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly promptName: string,
|
||||||
|
readonly variant: string
|
||||||
|
) {
|
||||||
|
super(`Prompt path escapes source directory for ${promptName}/${variant}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
input: LoadPromptInput
|
input: LoadPromptInput
|
||||||
): Promise<LoadedPrompt<TFrontmatter>> {
|
): Promise<LoadedPrompt<TFrontmatter>> {
|
||||||
const filePath = join(input.source.baseDir, input.name, `${input.variant}.md`)
|
const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant)
|
||||||
const content = await readPromptFile(input.name, input.variant, filePath)
|
const content = await readPromptFile(input.name, input.variant, filePath)
|
||||||
const parsed = parseFrontmatter<TFrontmatter>(content)
|
const parsed = parseFrontmatter<TFrontmatter>(content)
|
||||||
const body = await applyRuntimeInjections(parsed.body, input.inject ?? [])
|
const body = await applyRuntimeInjections(parsed.body, input.inject ?? [])
|
||||||
@@ -33,6 +44,16 @@ export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string {
|
||||||
|
const resolvedBaseDir = resolve(baseDir)
|
||||||
|
const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`)
|
||||||
|
const relativePath = relative(resolvedBaseDir, filePath)
|
||||||
|
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||||
|
throw new PromptPathTraversalError(promptName, variant)
|
||||||
|
}
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
async function readPromptFile(promptName: string, variant: string, filePath: string): Promise<string> {
|
async function readPromptFile(promptName: string, variant: string, filePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await readFile(filePath, "utf8")
|
return await readFile(filePath, "utf8")
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import {
|
|||||||
} from "@oh-my-opencode/model-core"
|
} from "@oh-my-opencode/model-core"
|
||||||
import type { VariantTable } from "./types"
|
import type { VariantTable } from "./types"
|
||||||
|
|
||||||
|
type ModelMatcher = (modelID: string) => boolean
|
||||||
|
|
||||||
export type ResolveVariantInput = {
|
export type ResolveVariantInput = {
|
||||||
readonly modelID?: string
|
readonly modelID?: string
|
||||||
readonly agentName?: string
|
readonly agentName?: string
|
||||||
readonly variants: VariantTable
|
readonly variants: VariantTable
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLANNER_AGENT_NAMES = new Set(["prometheus"])
|
const PLANNER_AGENT_NAMES: ReadonlySet<string> = new Set(["prometheus"] as const)
|
||||||
|
|
||||||
const MODEL_MATCHERS: Record<string, (modelID: string) => boolean> = {
|
const MODEL_MATCHERS: Readonly<Record<string, ModelMatcher>> = {
|
||||||
gpt: isGptModel,
|
gpt: isGptModel,
|
||||||
gemini: isGeminiModel,
|
gemini: isGeminiModel,
|
||||||
kimi: isKimiK2Model,
|
kimi: isKimiK2Model,
|
||||||
@@ -43,10 +45,7 @@ export function resolveVariant(input: ResolveVariantInput): string {
|
|||||||
|
|
||||||
if (variantNames.includes("default")) return "default"
|
if (variantNames.includes("default")) return "default"
|
||||||
|
|
||||||
const firstVariant = variantNames[0]
|
return variantNames[0]
|
||||||
if (firstVariant !== undefined) return firstVariant
|
|
||||||
|
|
||||||
throw new TypeError("resolveVariant requires at least one prompt variant")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlannerAgent(agentName: string | undefined): boolean {
|
function isPlannerAgent(agentName: string | undefined): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user