Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/secret-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions test/secret-file-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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");
Expand Down