diff --git a/CHANGELOG.md b/CHANGELOG.md index 4262231..54882a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Refuse to save pending Discord messages through symbolic-link destinations. - Protect legacy and source-backed user/server memory reads and writes from symbolic links, junctions, and path swaps. - Require a text response for `!ask` commands even when their prompt is phrased without a question mark. - Remove malformed `[REACT:]` directives from legacy Claude responses instead of sending them as Discord text. diff --git a/src/storage/pending.ts b/src/storage/pending.ts index 2c76dcb..42ac58f 100644 --- a/src/storage/pending.ts +++ b/src/storage/pending.ts @@ -1,7 +1,8 @@ import fs from "fs"; import path from "path"; import { TextChannel, Message } from "discord.js"; -import { PENDING_DIR } from "../config.js"; +import { MESSAGES_DIR, PENDING_DIR } from "../config.js"; +import { writeVerifiedUtf8File } from "./safeRead.js"; export function savePending(msg: Message) { const filename = `${msg.id}.txt`; @@ -13,7 +14,15 @@ export function savePending(msg: Message) { `---`, msg.content, ].join("\n"); - fs.writeFileSync(path.join(PENDING_DIR, filename), content, "utf-8"); + const saved = writeVerifiedUtf8File( + path.join(PENDING_DIR, filename), + content, + MESSAGES_DIR, + PENDING_DIR, + ); + if (!saved) { + throw new Error("Could not safely save pending message"); + } } export function removePending(msgId: string) { diff --git a/tests/pendingSafety.test.mjs b/tests/pendingSafety.test.mjs new file mode 100644 index 0000000..28860dd --- /dev/null +++ b/tests/pendingSafety.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +test("pending writes do not follow symbolic-link destinations", async (t) => { + const testDir = fs.mkdtempSync( + path.join(os.tmpdir(), "claudify-pending-symlink-"), + ); + t.after(() => fs.rmSync(testDir, { recursive: true, force: true })); + + const messagesDir = path.join(testDir, "messages"); + process.env.MESSAGES_DIR = messagesDir; + const { savePending } = await import("../build/storage/pending.js"); + + const outsideFile = path.join(testDir, "outside.txt"); + const pendingPath = path.join( + messagesDir, + "pending", + "222222222222222222.txt", + ); + fs.writeFileSync(outsideFile, "must not be overwritten", "utf8"); + try { + fs.symlinkSync(outsideFile, pendingPath); + } catch (error) { + if (error.code !== "EPERM" && error.code !== "EACCES") throw error; + t.skip("symbolic-link assertions require host permission"); + return; + } + + assert.throws( + () => savePending({ + id: "222222222222222222", + author: { tag: "user#0001" }, + channel: { name: "general" }, + channelId: "111111111111111111", + createdAt: new Date("2026-08-31T12:30:00.000Z"), + content: "pending entry", + }), + /safely save pending message/u, + ); + assert.equal(fs.readFileSync(outsideFile, "utf8"), "must not be overwritten"); +});