Skip to content

fix(security): create secret files atomically with owner-only permissions (#1264)#1267

Merged
Chris0Jeky merged 5 commits into
mainfrom
security/1264-atomic-restricted-create
Jul 2, 2026
Merged

fix(security): create secret files atomically with owner-only permissions (#1264)#1267
Chris0Jeky merged 5 commits into
mainfrom
security/1264-atomic-restricted-create

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Closes #1264 (seeded from the #1263/#1241 review; the Copilot bot round on #1263 flagged the same race).

What

New FirstRunBootstrapper.WriteRestrictedFile creates the file atomically with owner-only permissions and FileShare.None, then writes through that same handle:

  • Unix: FileStreamOptions.UnixCreateMode = UserRead | UserWrite — the file is born 0600.
  • Windows: the protected owner-only FileSecurity is passed to CreateFile itself via the in-box FileInfo.Create(FileMode, FileSystemRights, FileShare, int, FileOptions, FileSecurity) extension.
  • FileMode.CreateNew refuses to adopt a file someone pre-created at the path (and the helper never deletes a file it did not create).

Why

The previous sequence (File.CreateRestrictFileToCurrentUser → write) left a window where the empty temp file existed with the directory's default permissions; a racer could pre-open a read handle that survives the tightened ACL (ACL changes don't revoke open handles) and read the later-written JWT secret / connector key. Unreachable in the single-user deployment, but the claim 'the secret is never in an unprotected file' is now true at every instant.

Call sites

  • PersistValue temp file → WriteRestrictedFile(tempPath, payload) (+ existing atomic File.Move).
  • PreserveCorruptConfig .corrupt-* backup → atomic restricted create from File.ReadAllBytes (byte-faithful; no copy-then-restrict window), with a recovery-first fallback to the old copy + best-effort restrict so an ACL failure never costs the only copy of a recoverable key.
  • RestrictFileToCurrentUser is retained for forward remediation of EXISTING files (RestrictExistingLocalConfigFile, the backup fallback); its Windows DACL construction is extracted into a shared BuildOwnerOnlyFileSecurity.

Tests (42/42 FirstRun-filtered Api + 13/13 Cli, both green locally; each CI OS covers its platform branch)

  • WriteRestrictedFile_CreatesFileAtomicallyLockedToCurrentUser — born-restricted (0600 / protected single-SID DACL) + exact content.
  • WriteRestrictedFile_RefusesToAdoptAPreExistingFileCreateNew throws IOException, pre-existing file untouched.
  • QuarantineCorruptLocalConfigAt_BackupIsRestrictedToCurrentUser — backup restricted + byte-faithful.
  • WriteRestrictedFile_LockdownSurvivesAtomicMove — the fix(security): lock appsettings.local.json secrets to the current user on Windows (#1241) #1263 move-preservation test updated to mirror the new PersistValue sequence.

Out of scope

…ions (#1264)

WriteRestrictedFile creates with FileMode.CreateNew + FileShare.None and
the restrictive mode/DACL supplied at creation (Unix UnixCreateMode 0600;
Windows protected owner-only FileSecurity passed to CreateFile), then
writes through that handle. Closes the create-then-restrict window where
a racer's pre-opened handle on the empty temp file survived the tightened
ACL. PersistValue and the corrupt-config backup both use it; the backup
keeps a recovery-first fallback (plain copy + best-effort restrict) so an
ACL failure never costs the only copy of a recoverable key.
New: born-restricted assert (both platforms), CreateNew refuses a
pre-existing file without deleting it, quarantine backup is restricted +
byte-faithful. The atomic-move test now mirrors the new PersistValue
sequence (WriteRestrictedFile then File.Move).
Copilot AI review requested due to automatic review settings July 2, 2026 16:06

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request improves security when writing sensitive configuration files by introducing atomic file creation with owner-only permissions (WriteRestrictedFile), closing a race condition window where files were briefly readable by other local users. Corresponding unit tests were added to verify atomic lockdown, refusal of pre-existing files, and backup restriction. The reviewer suggested a performance improvement to use Encoding.UTF8.GetBytes instead of instantiating a new UTF8Encoding object on every call to avoid unnecessary allocations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread backend/src/Taskdeck.Api/FirstRun/FirstRunBootstrapper.cs Outdated
…es (#1267 review)

CreateFileW silently ignores the supplied security descriptor on non-ACL
filesystems (FAT32/exFAT/some SMB) and open(2)'s mode is ignored on
non-POSIX mounts -- where the pre-#1264 restrict calls FAILED. Restore the
fail-closed contract through the open handle: Windows reads the DACL back
(handle opened with ReadPermissions) and refuses an unprotected result;
Unix pins exactly 0600 via SetUnixFileMode(SafeFileHandle) -- also
umask-proof. Both clean up their own file on a failed lockdown. Also:
corrupt-backup fallback copies to a distinct .fallback name (primary
residue can no longer block it), honest 'primary backup:' error label,
CS0419 cref disambiguated.
…eview)

The behavioral tests cannot distinguish atomic create-with-permissions
from a regression to create-then-restrict (identical final state on NTFS
-- probe-proven by the review). Replace the now-vacuous
PersistValue_SetsUnixFileMode_StructuralCheck with checks pinning
WriteRestrictedFile wiring (temp + backup), creation-time permissions,
the banned old sequence, and the two fail-closed post-create guards.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Adversarial Code Review

Two independent review agents (filesystem-security API semantics + failure-paths/test-regression), both empirical: they built scratch net8.0 probes (Windows on this box; Unix in WSL) rather than assuming API behavior.

CRITICAL / HIGH

  • None. Empirically confirmed sound: the DACL is applied AT creation (with the handle open and before the first write, GetAccessControl already shows the protected single-SID descriptor — a plain File.Create inherits 9 ACEs incl. other users' SIDs, proving this closes a real FirstRunBootstrapper: create secret temp file atomically with owner-only ACL (close empty-file handle race) #1264 window); FileSystemRights.Write suffices for the write (CreateFileW adds SYNCHRONIZE implicitly); the protected DACL / 0600 survive File.Move incl. overwrite onto a default-ACL target; CreateNew never adopts or deletes a pre-existing file (on Linux, O_EXCL even refuses a dangling symlink — a hardening over the old File.Create); the exception surface is strictly NARROWER than before (the old File.Create's raw UnauthorizedAccessException bypassed callers' catch (IOException) — a latent startup-crash path, now normalized); UTF8-no-BOM output is byte-identical; PersistValue's mutex/cleanup/ReplaceFileWithRetry semantics exactly preserved; 39/39 tests passed on the PR head in an isolated worktree.

MEDIUM

  1. Fail-closed became fail-open on non-ACL filesystems (both lenses independently). CreateFileW silently IGNORES the supplied security descriptor on FAT32/exFAT/some SMB shares, and open(2)'s mode is ignored on vfat mounts — where the OLD SetAccessControl/SetUnixFileMode calls FAILED (fail-closed: in-memory fallback + the desktop-Production fatal error that literally names FAT32/exFAT). The new code would silently persist the JWT secret/connector key world-readable there (e.g. a portable install on an exFAT USB stick), contradicting the helper's own doc.
  2. No test pinned the load-bearing property (probe-proven). The reviewer implemented create-then-restrict in a scratch probe and it PASSED every test in the file — including PersistValue_SetsUnixFileMode_StructuralCheck, which had become vacuous (PersistValue no longer calls SetUnixFileMode; the assertion was satisfied by the unrelated remediation helper).

LOW

  1. Corrupt-backup double-failure corner: if the primary restricted create fails mid-write AND its cleanup delete fails (same-user AV/indexer handle), the residue made the fallback File.Copy(overwrite:false) fail on the SAME name — 'could NOT be backed up' while the source was still readable, then the original gets deleted. Also the error label said 'restricted create:' even when the failure came from File.ReadAllBytes.
  2. CS0419 ambiguous <see cref="WriteRestrictedFile"/> (now overloaded).
  3. Issue CliFirstRunBootstrapper: owner-only Windows ACL + fix write-before-chmod TOCTOU on the connector key file #1262's remediation text is stale: it still prescribes the create-then-restrict sequence this PR just replaced as insufficient.
  4. (Informational, probed): UnixCreateMode is umask-masked — can only STRIP bits (0600 is never weakened), but a pathological owner-bit umask could over-tighten below 0600 where the old chmod guaranteed exactness.

Bot Comments Addressed

  • None had landed at review time (any subsequent round will be addressed before merge).

Summary

0 CRITICAL / 0 HIGH / 2 MEDIUM / 3 LOW + 1 informational. MEDIUMs merge-blocking until fixed — all fixed, see follow-up.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Adversarial Review — Fixes Applied

Finding Severity Fix Commit Verified
Fail-open on non-ACL filesystems MEDIUM cf19f552 — Windows: DACL read back through the open handle (opened with Write | ReadPermissions; an unprotected/unreadable descriptor → normalized IOException, own file cleaned up). Unix: exact 0600 pinned via File.SetUnixFileMode(SafeFileHandle) (fchmod — race-free, umask-proof, fails on vfat exactly where the old path failed). Restores fail-closed everywhere the old code was, keeps atomicity on NTFS/POSIX. Also fixes finding 6 (umask over-tightening). 43/43 FirstRun (Api) + 13/13 (Cli); the initially-missing ReadPermissions was itself caught by the behavioral tests (read-back threw UnauthorizedAccessException on a Write-only handle)
Vacuous structural check / no regression pin MEDIUM 7f547efdPersistValue_SetsUnixFileMode_StructuralCheck replaced by PersistValue_WritesSecretsViaAtomicRestrictedCreate_StructuralCheck (pins WriteRestrictedFile wiring for temp + backup, creation-time permissions, bans File.Create(tempPath) / RestrictFileToCurrentUser(tempPath)) + WriteRestrictedFile_FailsClosedOnNonAclFilesystems_StructuralCheck (pins both post-create guards). The probe's create-then-restrict regression now fails 2 structural checks
Backup double-failure residue + misleading label LOW cf19f552 — fallback copies to a distinct .fallback name (primary residue can no longer block it; the final 'preserved at' message tracks the right path); label reworded to 'primary backup:' (honestly covers a ReadAllBytes failure too). FirstRun suite green
CS0419 ambiguous cref LOW cf19f552<see cref="WriteRestrictedFile(string, string)"/>. grep -c 'warning CS0419' on a full Release build → 0
#1262 stale remediation text LOW Issue #1262 updated (comment) to prescribe the atomic WriteRestrictedFile pattern instead of create-then-restrict.

All findings addressed. CI status: PENDING on 7f547efd.

@Chris0Jeky Chris0Jeky merged commit 408e46b into main Jul 2, 2026
20 checks passed
@Chris0Jeky Chris0Jeky deleted the security/1264-atomic-restricted-create branch July 2, 2026 17:21
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

FirstRunBootstrapper: create secret temp file atomically with owner-only ACL (close empty-file handle race)

2 participants