From 210ae64816e7326ff1ff2ea72b73ecdfd3643dcd Mon Sep 17 00:00:00 2001 From: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:19:44 -0700 Subject: [PATCH 1/2] Reject symbolic-link draft input files --- src/utils/bounded-file.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/bounded-file.ts b/src/utils/bounded-file.ts index e4308ba..c5694f6 100644 --- a/src/utils/bounded-file.ts +++ b/src/utils/bounded-file.ts @@ -1,12 +1,17 @@ import { constants } from "node:fs"; -import { open } from "node:fs/promises"; +import { lstat, open } from "node:fs/promises"; /** Read a bounded regular UTF-8 file, including a bound if it grows after stat. */ export async function readBoundedFile(path: string, maxBytes: number): Promise { + // lstat rejects symbolic links before opening them. Compare the opened file + // with that entry too, so a replacement between the check and open cannot + // redirect a draft command to a different file. + const entry = await lstat(path); + if (!entry.isFile()) throw new Error(`Expected a regular file no larger than ${maxBytes} bytes.`); const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); try { const stat = await file.stat(); - if (!stat.isFile() || stat.size > maxBytes) throw new Error(`Expected a regular file no larger than ${maxBytes} bytes.`); + if (!stat.isFile() || stat.size > maxBytes || stat.dev !== entry.dev || stat.ino !== entry.ino) throw new Error(`Expected a regular file no larger than ${maxBytes} bytes.`); const bytes = Buffer.alloc(maxBytes + 1); let size = 0; while (size < bytes.length) { From ffdbb261d522cc9d0cd03fa1abccf429f39eb689 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:19:51 -0700 Subject: [PATCH 2/2] Reject symbolic-link draft input files --- src/__tests__/operator-workflow.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/__tests__/operator-workflow.test.ts b/src/__tests__/operator-workflow.test.ts index de3850d..270cf53 100644 --- a/src/__tests__/operator-workflow.test.ts +++ b/src/__tests__/operator-workflow.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runOperator } from "../operator-cli.js"; @@ -79,6 +79,19 @@ describe("operator draft workflow commands", () => { expect(JSON.parse(output.error.mock.calls[0][0])).toMatchObject({ format_version: 1, ok: false, command: "drafts create", code: "invalid_input_file" }); }); + it("rejects a symbolic-link Markdown file before loading credentials", async context => { + const target = file("linked-target.md", "Hello"); + const path = join(dir, "linked.md"); + try { symlinkSync(target, path, "file"); } catch (error) { + if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") { context.skip(); return; } + throw error; + } + const fetch = vi.fn(), load = vi.fn(), output = io(); vi.stubGlobal("fetch", fetch); + expect(await runOperator(["drafts", "create", path, "--title", "T"], load, output)).toBe(2); + expect(load).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); expect(output.out).not.toHaveBeenCalled(); + expect(JSON.parse(output.error.mock.calls[0][0])).toMatchObject({ code: "invalid_input_file" }); + }); + it("admits a 1 MiB file, then applies the converter's own bound before any request", async () => { const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); const output = io();