-
Notifications
You must be signed in to change notification settings - Fork 34
security: refuse to write credentials through pre-existing symlinks #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { constants } from 'node:fs'; | ||
| import fs from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
|
|
||
|
|
@@ -8,20 +9,61 @@ export async function writeCredentialFile( | |
| ): Promise<string> { | ||
| const resolved = path.resolve(filePath); | ||
|
|
||
| if (!force) { | ||
| // Atomically create the output file with O_EXCL | O_NOFOLLOW so we never | ||
| // write through a pre-existing symlink. The previous implementation used | ||
| // fs.access() then fs.writeFile() which followed symlinks: an attacker on | ||
| // a shared filesystem (CI runner, multi-user host, container with shared | ||
| // tmp) could pre-plant a symlink at the operator's --output-file path, | ||
| // pre-open a file descriptor against the symlink target while it was | ||
| // world-readable, then read the credential through that fd after the | ||
| // operator's writeFile resolved the symlink and wrote the card data. | ||
| // The follow-up fs.chmod(resolved, 0o600) would then race-finalize the | ||
| // target permissions to owner-only — too late, the attacker's fd was | ||
| // open before chmod and survives it. | ||
| // | ||
| // O_NOFOLLOW makes open() refuse to traverse the final path component | ||
| // when it is a symbolic link (returns ELOOP). O_EXCL makes open() refuse | ||
| // to operate on a pre-existing file (returns EEXIST). The mode argument | ||
| // is only consulted when O_CREAT actually creates the file — combined | ||
| // with O_EXCL this guarantees the file is created here and nowhere else. | ||
| if (force) { | ||
| // Remove any pre-existing entry, including a symlink. Use fs.unlink | ||
| // which operates on the symlink itself rather than its target. ENOENT | ||
| // is fine; anything else (EISDIR, EACCES) surfaces to the caller. | ||
| try { | ||
| await fs.access(resolved); | ||
| await fs.unlink(resolved); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; | ||
| } | ||
| } | ||
|
|
||
| let handle: fs.FileHandle | undefined; | ||
| try { | ||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like O_NOFOLLOW is not supported on windows. You can see in a similar vulnerability in a 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 |
||
| 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.`, | ||
| ); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; | ||
| } | ||
| if (code === 'ELOOP') { | ||
| throw new Error( | ||
| `OUTPUT_FILE_SYMLINK: ${resolved} is a symbolic link. Refusing to write credentials through it.`, | ||
| ); | ||
| } | ||
| throw err; | ||
| } | ||
|
|
||
| await fs.writeFile(resolved, JSON.stringify(data, null, 2), { | ||
| mode: 0o600, | ||
| }); | ||
| await fs.chmod(resolved, 0o600); | ||
| try { | ||
| await handle.write(JSON.stringify(data, null, 2)); | ||
| } finally { | ||
| await handle.close(); | ||
| } | ||
| return resolved; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we cut down on the comment size here to make this more concise?