Skip to content
Open
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
52 changes: 52 additions & 0 deletions packages/cli/src/utils/__tests__/credential-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,56 @@ describe('writeCredentialFile', () => {
const result = await writeCredentialFile(filePath, {}, false);
expect(path.isAbsolute(result)).toBe(true);
});

// The output file holds full card credentials. A pre-existing symbolic
// link at the user's --output-file path lets an attacker on a shared
// filesystem redirect the credential write through the link target.
// Refusing to follow the link closes the cross-user exfiltration vector.
// --force does not authorize symlink replacement: an operator who wants
// to overwrite a regular output file gets that, but a symlink at the
// path is rejected outright in either mode.

it('refuses to write through a symbolic link without force', async () => {
const filePath = path.join(tmpDir, 'card.json');
const targetPath = path.join(tmpDir, 'target.json');
await fs.writeFile(targetPath, '{}');
await fs.symlink(targetPath, filePath);

await expect(
writeCredentialFile(filePath, { num: 1 }, false),
).rejects.toThrow('OUTPUT_FILE_SYMLINK');
// The symlink target must not have been written.
expect(await fs.readFile(targetPath, 'utf-8')).toBe('{}');
// The symlink itself must still be in place.
expect((await fs.lstat(filePath)).isSymbolicLink()).toBe(true);
});

it('refuses to overwrite a symbolic link even with --force', async () => {
const filePath = path.join(tmpDir, 'card.json');
const targetPath = path.join(tmpDir, 'target.json');
await fs.writeFile(targetPath, '{}');
await fs.symlink(targetPath, filePath);

// --force is intended to replace an existing regular output file. It
// does not authorize destroying a symlink the operator may not have
// intended to remove, nor writing credentials over its target.
await expect(
writeCredentialFile(filePath, { num: 1 }, true),
).rejects.toThrow('OUTPUT_FILE_SYMLINK');
// The symlink target must not have been written.
expect(await fs.readFile(targetPath, 'utf-8')).toBe('{}');
// The symlink itself must still be in place.
expect((await fs.lstat(filePath)).isSymbolicLink()).toBe(true);
});

it('produces a 0o600 file even when force is set', async () => {
// Mode is set at create time via the open() third argument, with
// O_EXCL guaranteeing the file is created here. Validates the mode
// path explicitly because the legacy chmod-after-write step is gone.
const filePath = path.join(tmpDir, 'card.json');
await fs.writeFile(filePath, 'old', { mode: 0o644 });
await writeCredentialFile(filePath, { x: 1 }, true);
const stat = await fs.stat(filePath);
expect(stat.mode & 0o777).toBe(0o600);
});
});
67 changes: 58 additions & 9 deletions packages/cli/src/utils/credential-output.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { constants, type Stats } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';

Expand All @@ -8,20 +9,68 @@ export async function writeCredentialFile(
): Promise<string> {
const resolved = path.resolve(filePath);

if (!force) {
try {
await fs.access(resolved);
// Atomically create the credential file so we never write through a
// pre-existing output path. lstat first to inspect what is there: a
// symlink at the final path component is rejected outright (even with
// --force), since --force is meant to replace an existing output file,
// not to authorize writing credentials over or through a symlink. A
// regular file is overwritten only when --force is set. The atomic
// create below uses O_EXCL | O_NOFOLLOW so the race window between the
// precheck and the create is fail-closed.
let existing: Stats | undefined;
try {
existing = await fs.lstat(resolved);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}

if (existing?.isSymbolicLink()) {
throw new Error(
`OUTPUT_FILE_SYMLINK: ${resolved} is a symbolic link. Refusing to write credentials through it.`,
);
}

if (existing) {
if (!force) {
throw new Error(
`OUTPUT_FILE_EXISTS: ${resolved} already exists. Use --force to overwrite.`,
);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
await fs.unlink(resolved);
}
Comment on lines +12 to +40
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry one more q - in what context would this be triggered and how has its behavior changed? Why would we want to unlink a symlinked file? Before fs.access was just testing access permissions - why do we want to now potentially unlink? Wouldn't that invalidate the checks below about not following symlinked files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the close read. Fair point that the prior --force flow didn't fully match the no-follow contract.

The unlink runs only when --force is set, and it clears the path so the subsequent open with O_CREAT | O_EXCL can succeed. For regular files that preserves the prior --force semantics (replace the existing output file), with the credential landing in a fresh 0o600 inode rather than racing a chmod on an already-open file descriptor.

For symlinks specifically, the no-follow guarantee doesn't cover the existing entry. The unlink runs before O_NOFOLLOW can fire, so the protection only covers the race window after the unlink, not the symlink that was already there. The credential never reaches the link target, but --force ends up silently destroying a symlink the operator may not have intended to remove.

Pushed a fix: lstat first, refuse if the final path is a symlink (with or without --force), unlink and recreate only for non-symlink existing files. O_EXCL | O_NOFOLLOW stays as the race defense between the precheck and the atomic create. With this, --force replaces an existing regular output file and rejects everything else.

Thanks again for the careful pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part of the change makes sense. Why don't we simply add that check up top and leave the rest of the code unchanged? I'm lost on why we need all the changes below. I'd propose we keep things simply

let existing: Stats | undefined;
  try {
    existing = await fs.lstat(resolved);
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
  }

  if (existing?.isSymbolicLink()) {
    throw new Error(
      `OUTPUT_FILE_SYMLINK: ${resolved} is a symbolic link. Refusing to write as symbolic links can open a TOCTOU security threat.`,
    );
  }

if (!force) {
    try {
      await fs.access(resolved);
      throw new Error(
        `OUTPUT_FILE_EXISTS: ${resolved} already exists. Use --force to overwrite.`,
      );
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
    }
  }

  await fs.writeFile(resolved, JSON.stringify(data, null, 2), {
    mode: 0o600,
  });
  await fs.chmod(resolved, 0o600);
  return resolved;

That would make the diff much more reasonable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I agree the current diff is larger than ideal.

The part I'm hesitant to drop is the final open path. The lstat check up front is useful for clearer errors, but it is still only a precheck. If we go back to fs.writeFile, the actual write resolves the path again and follows symlinks.

The other subtle bit is mode: 0o600: it only applies when a new file is created. In the --force path, if the destination already exists, writeFile writes first and the explicit chmod happens after the credential bytes are already on disk.

So I think we need two things:

  1. Reject an existing symlink at the output path.
  2. Make the actual credential write create a fresh 0o600 file atomically.

Your suggested lstat block covers the first one. The fs.open(O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600) part is what covers the second one.

A concrete --force race I'm trying to avoid:

T0: we lstat and see no file at resolved
T1: another local user creates resolved as a symlink to a file they can already read
T2: because force=true, the fs.access check is skipped
T3: fs.writeFile follows the symlink and writes the credential into that target
T4: fs.chmod follows the symlink and locks the target down to 0o600, but the credential was already written through the symlink

I can definitely trim the implementation and comments so the diff is smaller. My proposal would be:

  • keep your lstat precheck for clear symlink errors
  • keep --force limited to replacing existing regular files
  • keep the final write as atomic fs.open(... O_EXCL | O_NOFOLLOW, 0o600) plus handle.write

That should keep the PR focused while preserving the part that closes the TOCTOU.


await fs.writeFile(resolved, JSON.stringify(data, null, 2), {
mode: 0o600,
});
await fs.chmod(resolved, 0o600);
// O_NOFOLLOW is POSIX-only. On Windows, Node exposes it as 0, so the
// bitwise OR is a no-op there: O_EXCL still prevents overwriting a
// pre-existing entry, but open() does not provide full no-follow
// semantics on Windows.
const noFollowFlag = constants.O_NOFOLLOW ?? 0;

let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(
resolved,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollowFlag,
0o600,
);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EEXIST') {
throw new Error(
`OUTPUT_FILE_EXISTS: ${resolved} already exists. Use --force to overwrite.`,
);
}
if (code === 'ELOOP') {
throw new Error(
`OUTPUT_FILE_SYMLINK: ${resolved} is a symbolic link. Refusing to write credentials through it.`,
);
}
throw err;
}

try {
await handle.write(JSON.stringify(data, null, 2));
} finally {
await handle.close();
}
return resolved;
}