From f64694f894e5f7690f09e7ad5efb2aa4c1c7c1f2 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 23 Apr 2026 23:53:18 +0000 Subject: [PATCH] fix: validate companyId/instanceId against path traversal (N1) Co-Authored-By: Claude Sonnet Co-Authored-By: Paperclip --- src/server/prompt-cache.test.ts | 49 +++++++++++++++++++++++++++++++++ src/server/prompt-cache.ts | 9 ++++++ 2 files changed, 58 insertions(+) create mode 100644 src/server/prompt-cache.test.ts diff --git a/src/server/prompt-cache.test.ts b/src/server/prompt-cache.test.ts new file mode 100644 index 0000000..dc14372 --- /dev/null +++ b/src/server/prompt-cache.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { prepareClaudePromptBundle } from "./prompt-cache.js"; + +const onLog = vi.fn(); + +describe("prepareClaudePromptBundle path traversal validation", () => { + const validArgs = { + skills: [], + instructionsContents: null, + onLog, + }; + + it("rejects companyId containing ..", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: ".." })).rejects.toThrow(/companyId/); + }); + + it("rejects companyId containing ../x", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "../x" })).rejects.toThrow(/companyId/); + }); + + it("rejects companyId containing /", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a/b" })).rejects.toThrow(/companyId/); + }); + + it("rejects companyId containing backslash", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a\\b" })).rejects.toThrow(/companyId/); + }); + + it("rejects companyId containing null byte", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "a\0b" })).rejects.toThrow(/companyId/); + }); + + it("rejects empty companyId", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: "" })).rejects.toThrow(/companyId/); + }); + + it("rejects whitespace-only companyId", async () => { + await expect(prepareClaudePromptBundle({ ...validArgs, companyId: " " })).rejects.toThrow(/companyId/); + }); + + it("accepts a valid companyId", async () => { + vi.stubEnv("PAPERCLIP_HOME", path.join(os.tmpdir(), `prompt-cache-test-${process.pid}`)); + const result = await prepareClaudePromptBundle({ ...validArgs, companyId: "acme-co" }); + expect(result.rootDir).toContain("acme-co"); + vi.unstubAllEnvs(); + }); +}); diff --git a/src/server/prompt-cache.ts b/src/server/prompt-cache.ts index 6ab06fb..6fcc2ea 100644 --- a/src/server/prompt-cache.ts +++ b/src/server/prompt-cache.ts @@ -21,6 +21,13 @@ export interface ClaudePromptBundle { const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; +function validatePathComponent(value: string, fieldName: string): void { + if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`); + if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`); + if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`); + if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`); +} + function resolveManagedClaudePromptCacheRoot(companyId: string): string { const paperclipHome = (typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0 @@ -31,6 +38,8 @@ function resolveManagedClaudePromptCacheRoot(companyId: string): string { (typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0 ? process.env.PAPERCLIP_INSTANCE_ID.trim() : null) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + validatePathComponent(companyId, "companyId"); + validatePathComponent(instanceId, "instanceId"); return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache"); }