Skip to content

security: secret files are born 0600 instead of chmod-after-write - #380

Open
Saidheerajgollu wants to merge 1 commit into
andrewyng:mainfrom
Saidheerajgollu:security/secrets-born-private
Open

security: secret files are born 0600 instead of chmod-after-write#380
Saidheerajgollu wants to merge 1 commit into
andrewyng:mainfrom
Saidheerajgollu:security/secrets-born-private

Conversation

@Saidheerajgollu

Copy link
Copy Markdown

SecretStore._write (secrets.json — every connector/MCP credential) and write_private_text (the sidecar API token) both write the tmp file with Path.write_text and restrict it to the user afterwards:

tmp.write_text(content, encoding="utf-8")   # created 0666 & ~umask -> typically 0644
_restrict_to_user(tmp, is_dir=False)        # only now 0600

Two problems with privatizing after the fact:

  1. Between the write and the chmod the full plaintext content sits umask-wide on disk. The state dir is normally 0700, which covers secrets.json — but write_private_text takes arbitrary paths and its parent-dir restriction is explicitly best-effort (except OSError: pass), so the token file can land readable in a directory the user doesn't own.
  2. write_text follows symlinks. The .tmp name is predictable, so a symlink parked there receives the secret into its target: the content is poured into the victim file, the chmod is applied to the victim, and os.replace then renames the symlink over the real target. Reproduced on main — the third test below fails there with the victim file containing the secret.

The fix opens the tmp file with os.open(..., O_WRONLY | O_CREAT | O_EXCL, 0o600): private from its first byte, and O_EXCL refuses to write through anything pre-planted at that name (a stale tmp from a crash is unlinked first; if an attacker re-plants between the unlink and the open, the create fails closed instead of following). The follow-up _restrict_to_user call stays — on Windows privacy is the ACL, not mode bits, and that path is unchanged.

Three regression tests, each red on current main: the two "private even if the restrict step does nothing" tests (mode is 0644 there today under a zeroed umask), and the pre-planted-symlink test. Full suite: 1018 passed.

secrets.json (all connector/MCP credentials) and the sidecar API token
were written with Path.write_text — created umask-wide, typically 0644 —
and only restricted to the user afterwards. That leaves a window where
the content is world-readable, and no protection at all on the
best-effort paths. Open the tmp file with mode 0600 instead so the
content is private for its whole lifetime.

O_EXCL also stops write_text's symlink-following: a pre-planted symlink
at the predictable .tmp name used to receive the secret into its target.
A stale tmp left by a crash is removed before the exclusive create.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant