security: refuse to write credentials through pre-existing symlinks#94
security: refuse to write credentials through pre-existing symlinks#94garagon wants to merge 3 commits into
Conversation
writeCredentialFile (cli/src/utils/credential-output.ts) was vulnerable to
a TOCTOU exfiltration on shared filesystems. The previous flow was:
fs.access(resolved) // follows symlinks
fs.writeFile(resolved, ..., { mode: 0o600 }) // follows symlinks; mode
// applied only if file is
// created, not if exists
fs.chmod(resolved, 0o600) // follows symlinks; sets perms on TARGET
If the operator runs `spend-request retrieve <id> --output-file <path>
--force` and an attacker on the same filesystem (CI runner, multi-tenant
host, container with shared /tmp) pre-plants a symlink at <path> pointing
at a file the attacker can already read, the attacker:
1. Plants <path> as a symbolic link to /tmp/<readable>.
2. Opens a read fd on /tmp/<readable> while it is world-readable.
3. Operator runs the command: writeFile follows the symlink and writes
the full card credential (PAN, CVV, billing address, valid_until) to
the symlink target.
4. Operator's chmod 0o600 then locks the target down — too late, the
attacker's fd was opened before the chmod and survives it.
Verified end-to-end by a Node reproduction: a fresh fd opened against the
target before the operator's writeCredentialFile call reads the JSON-
encoded credential immediately after the call returns, regardless of the
chmod that follows.
Fix replaces fs.access + fs.writeFile + fs.chmod with a single
fs.open(resolved, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600). The
mode is set at create time. O_NOFOLLOW makes open fail with ELOOP if the
final path component is a symlink. O_EXCL makes open fail with EEXIST if
the file already exists. Force-mode unlinks any pre-existing entry first
(operating on the symlink itself via fs.unlink, not the target via
fs.writeFile), then takes the same atomic-create path.
Tests added to credential-output.test.ts:
- refuses to write through a symbolic link without force
- refuses to write through a symbolic link with force (target untouched)
- TOCTOU race-fail-closed
- 0o600 mode produced even with --force (regression guard for the
removed fs.chmod step)
`pnpm test` is green (117/117 across 11 files). Negative control: with
the implementation reverted, two of the new tests fail because the
symlink target is overwritten by the credential JSON.
raubrey-stripe
left a comment
There was a problem hiding this comment.
Thank you for this, some quick feedback + windows support!
| const resolved = path.resolve(filePath); | ||
|
|
||
| if (!force) { | ||
| // Atomically create the output file with O_EXCL | O_NOFOLLOW so we never |
There was a problem hiding this comment.
Could we cut down on the comment size here to make this more concise?
There was a problem hiding this comment.
Addressed in cc1a135: comment now captures the operational why only. The full attack chain stays in the PR body and tests.
| handle = await fs.open( | ||
| resolved, | ||
| // biome-ignore lint/suspicious/noBitwiseInsideUnaryExpression: standard open(2) flag composition | ||
| constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, |
There was a problem hiding this comment.
It looks like O_NOFOLLOW is not supported on windows.
You can see in a similar vulnerability in a filelock python package here took a Windows specific patch in the "Patches" section.
If you're able we'd very much appreciate an investigation into the windows specific fix as well! But in the meantime, could you document this fix only applies to Posix? We can do a check for constants.O_NOFOLLOW !== 0 and make a decision about whether we support --auth-file as well here 🤔 cc @danhill-stripe
There was a problem hiding this comment.
Addressed in cc1a135: flag is now constants.O_NOFOLLOW ?? 0 with an inline note on the Windows gap (O_EXCL still prevents overwriting a pre-existing entry, but no full no-follow semantics there). The proposed Windows fix and the plan to validate it on Windows CI before promising it are in the top-level comment.
Addresses review feedback on PR stripe#94. Gates O_NOFOLLOW as constants.O_NOFOLLOW ?? 0 with an inline note on the Windows gap (O_EXCL still prevents overwriting a pre-existing entry, but the path no longer provides full no-follow protection there). Trims in-file comment; the full attack chain stays in the PR body and tests. Drops the biome-ignore directive that pointed at a non-existent rule.
|
Thanks for the review and the GHSA reference. Both points make sense. In-file comment trimmed to the operational "why". Attack chain stays in the PR body and tests. On Windows: agreed. The flag is now For the Windows fix itself, I'd rather not promise it without validating on Windows CI first. The pattern I have in mind is |
raubrey-stripe
left a comment
There was a problem hiding this comment.
Thanks for the updates! Ack on the windows front. One more piece of feedback, let's focus on this first if (force) block. How are you changing the behavior there?
Addresses review feedback on PR stripe#94. The previous revision unlinked any pre-existing entry in --force mode and then ran the atomic open with O_EXCL | O_NOFOLLOW. That cleared the path for the create but also silently destroyed pre-existing symlinks, and the no-follow guarantee only covered the race window after the unlink. This refactor inverts the precheck: lstat first, refuse outright when the final path is a symlink (with or without --force), and only unlink and recreate for non-symlink existing files. O_EXCL | O_NOFOLLOW remains the race defense between the precheck and the atomic create. Tests updated. The without-force symlink case now expects OUTPUT_FILE_SYMLINK and asserts the symlink survives. The with-force case is renamed to make the new contract obvious and gets the same assertions. The dedicated post-unlink-race test is dropped because its premise (force unlinks the symlink) no longer applies; the regular-file race defense is exercised by the existing 0o600 test.
Summary
writeCredentialFile(packages/cli/src/utils/credential-output.ts, added in #67) is vulnerable to a TOCTOU exfiltration on shared filesystems. The previous flow was:If the operator runs
spend-request retrieve <id> --output-file <path> --forceand an attacker on the same filesystem (CI runner, multi-tenant host, container with shared/tmp) pre-plants a symbolic link at<path>pointing at a file the attacker can already read, the attacker can:<path>as a symbolic link to/tmp/<readable>./tmp/<readable>while it is still world-readable.writeFilefollows the symlink and writes the full card credential (PAN, CVC, billing address,valid_until) to the symlink target.fs.chmodthen locks the target down to0o600— too late, the attacker's fd was opened before the chmod and survives it (open fds keep their access mode regardless of subsequent permission changes).Verified end-to-end by a Node reproduction. Pre-fix, the attacker fd reads the JSON-encoded credential immediately after the operator's
writeCredentialFilecall returns:Fix
Replace
fs.access+fs.writeFile+fs.chmodwith a single atomic open:chmodfollow-up step is unnecessary (and the symlink-following bug it introduced is gone).O_NOFOLLOWmakesopenfail withELOOPif the final path component is a symbolic link.O_EXCLmakesopenfail withEEXISTif the file already exists.fs.unlink(operating on the symlink itself rather than the target viafs.writeFile), then takes the same atomic-create path. A re-planted symlink that races into place between the unlink and the open causesopento fail-closed instead of writing through.Post-fix verification:
Tests
packages/cli/src/utils/__tests__/credential-output.test.ts— four new cases:refuses to write through a symbolic link without force— symlink at the target path causesOUTPUT_FILE_EXISTS, and the link target is unchanged.refuses to write through a symbolic link with force—--forceunlinks the symlink and atomically creates a fresh regular file at the path. The original link target is unchanged. The new file is a regular file (not a symlink) with mode0o600.refuses to write through a symlink that races into place between unlink and create— sanity check on the--forcerace-shape. The fail-closed semantics are guaranteed byO_EXCL | O_NOFOLLOW.produces a 0o600 file even when force is set— regression guard for the removedfs.chmodstep. Mode is now set atopen()time via the third argument.Negative control: with the implementation reverted, two of the new tests fail because the symlink target is overwritten by the credential JSON (the existing
it('overwrites existing file with force', …)test still passes — the previous behavior was technically "compatible" with that assertion).Test plan
pnpm vitest run src/utils/__tests__/credential-output.test.tsgreen (8/8)pnpm testgreenpnpm typecheckgreen--force, the call refuses up front. With--force, the symlink is replaced by a fresh regular file at the target path; the original symlink target is unmodified and the attacker fd reads the pre-existing contents.Files