From e5cb2227f4a949e30c8db55c8904b0054970774f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 23:46:01 -0700 Subject: [PATCH] fix: pin synchronous secret preview identity --- CHANGELOG.md | 4 ++++ src/secret-file.ts | 10 +++++++++- test/secret-file-failure.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05276d2..ccc366e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Security and Correctness + +- Reject synchronous secret reads when the path is retargeted after the preview check, matching the asynchronous reader's `path-mismatch` contract instead of returning bytes from the replacement file. + ## 0.5.2 - 2026-08-02 ### Security and Correctness diff --git a/src/secret-file.ts b/src/secret-file.ts index 0ef07d3..eb05a60 100644 --- a/src/secret-file.ts +++ b/src/secret-file.ts @@ -123,8 +123,16 @@ function readSecretFileOutcomeSync( message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`, }; } - try { + if (!sameFileIdentity(previewStat, opened.stat)) { + const error = new FsSafeError("path-mismatch", "security validation failed"); + return { + ok: false, + code: "path-mismatch", + error, + message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`, + }; + } const raw = readFileDescriptorBoundedSync(opened.fd, maxBytes).toString("utf8"); const secret = raw.trim(); if (!secret) { diff --git a/test/secret-file-failure.test.ts b/test/secret-file-failure.test.ts index e9ee16b..2977c89 100644 --- a/test/secret-file-failure.test.ts +++ b/test/secret-file-failure.test.ts @@ -2,12 +2,14 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { expectFsSafeError, expectFsSafeErrorSync } from "./helpers/security.js"; import { itPosix, itWin32, useTempDirs } from "./helpers/vitest.js"; import { readSecretFileSync, tryReadSecretFileSync, writeSecretFileAtomic, } from "../src/secret-file.js"; +import { readSecretFile } from "../src/secret-read-async.js"; const { tempRoot } = useTempDirs(); @@ -126,6 +128,34 @@ describe("secret file refusal paths", () => { ); }); + itPosix("rejects a secret path retargeted after the preview in sync and async readers", async () => { + const root = await tempRoot("fs-safe-secret-preview-retarget-"); + const originalPath = path.join(root, "original"); + const replacementPath = path.join(root, "replacement"); + const syncLink = path.join(root, "sync-token"); + const asyncLink = path.join(root, "async-token"); + await fs.writeFile(originalPath, "original"); + await fs.writeFile(replacementPath, "replacement"); + await fs.symlink(originalPath, syncLink); + await fs.symlink(originalPath, asyncLink); + + const realpathSync = fsSync.realpathSync.bind(fsSync); + vi.spyOn(fsSync, "realpathSync").mockImplementationOnce((candidate, options) => { + fsSync.unlinkSync(syncLink); + fsSync.symlinkSync(replacementPath, syncLink); + return realpathSync(candidate, options as never); + }); + expectFsSafeErrorSync(() => readSecretFileSync(syncLink, "sync token"), "path-mismatch"); + + const realpath = fs.realpath.bind(fs); + vi.spyOn(fs, "realpath").mockImplementationOnce(async (candidate, options) => { + await fs.unlink(asyncLink); + await fs.symlink(replacementPath, asyncLink); + return await realpath(candidate, options as never); + }); + await expectFsSafeError(readSecretFile(asyncLink, "async token"), "path-mismatch"); + }); + it("rejects a different descriptor identity during post-write verification", async () => { const root = await tempRoot("fs-safe-secret-write-identity-"); const filePath = path.join(root, "token");