Skip to content

fix(swift): create the client-id file with owner-only permissions - #413

Closed
Josh (joshmouch) wants to merge 3 commits into
microsoft:mainfrom
joshmouch:pr/swift-clientidstore-perms
Closed

fix(swift): create the client-id file with owner-only permissions#413
Josh (joshmouch) wants to merge 3 commits into
microsoft:mainfrom
joshmouch:pr/swift-clientidstore-perms

Conversation

@joshmouch

@joshmouch Josh (joshmouch) commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

FileClientIdStore wrote the client id and only then restricted the file:

try data.write(to: url, options: [.atomic])
try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)

Between those two calls the id is on disk at the final, predictable path with the umask default — 0644 under a typical umask — and readable by any other local user.

This is the Swift side of the issue David Fowler (@davidfowl) fixed for the .NET client in #411. The .NET store was a port of this one, so the same shape was inherited; #411 is dotnet-only, so Swift still had it.

Scope — what is and isn't affected

  • First store per host id only. On overwrite, Foundation's atomic replace preserves the existing file's mode, so a rotating id does not re-open the window. I checked this expecting the opposite.
  • Not covered by the store's own directory hardening. ensureDirectory() applies 0700 only on the branch where it creates the directory, so a pre-existing Application Support or XDG path keeps its own mode (0755 by default) and the window is reachable.

The fix

Open the temp file with an explicit mode before any bytes are written, fchmod it to pin the mode, then rename(2) it into place:

let fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600)
_ = fchmod(fd, 0o600)

rename(2) is atomic and carries the source's mode onto the destination, so the id is never present at loose permissions, readers still see either the old id or the new one, and a file left looser by an earlier version is repaired by the next store.

Why both open with a mode and fchmod, rather than just the first: open(2)'s mode argument is masked by the process umask, so create-with-mode alone is not a strict improvement on the chmod-after-write shape it replaces — it closes the race but gives up the exact-mode guarantee:

umask open(0600) alone open + fchmod
0000 / 0022 / 0077 / 0177 0600 0600
0277 0400 (read-only) 0600

fchmod is not masked. This matches the netstandard2.0 leg of #411, which does the same two steps.

Tests

swift test: 125 passed, 0 failed.

  • testClientIdIsNeverObservableAtLoosePermissionsDuringStore — watches the destination directory while a store runs and fails if any file in it is ever seen carrying group or other bits. Against the previous implementation it fails naming the leaked file: XCTAssertNil failed: "h.clientid.sb-7121ee72-YVJU0v was 0644" — that's Foundation's atomic-write temp, holding the id at 0644. Observed in 19 of 20 runs pre-fix, 0 of 20 after. The assertion is "nothing was ever loose", which the current implementation satisfies by construction, so it cannot fail spuriously; a 1 MB payload widens the write so that missing a real regression is vanishingly unlikely.
  • testPersistedModeIsExactlyOwnerOnlyRegardlessOfUmask — fails with 0400 if the fchmod is removed. Also asserts the value reads back, since a file the owner cannot read is not a fix.
  • testFileIsRestrictedToOwnerWhenPossible was wrapped in if let attrs = try? … with no else, so it passed vacuously when the attribute read failed. It now asserts unconditionally — an improvement independent of this bug.

I first shipped this without the window test, on the reasoning that a two-syscall race isn't observable in-process. That was wrong, and worth stating: the earlier attempt failed because the poller walked 64 named paths per iteration behind a Task.yield(), not because the window is too narrow. Writing the test properly is also what surfaced the umask gap above.

Notes

  • Adds the first ### Security heading to the Swift changelog. The file uses Added/Changed/Fixed/Removed; Security is a standard Keep a Changelog category, but say the word and I'll fold it into ### Fixed.
  • Platforms are Apple-only per the root Package.swift, so no Glibc conditional is needed.

FileClientIdStore wrote the client id with Data.write(.atomic) and only then
restricted the file to 0600. Between those two calls the id was on disk at the
final, predictable path with the umask default (0644 under a typical umask),
readable by any other local user.

The store's own 0700 on its directory does not cover this: ensureDirectory()
only applies it on the branch where it creates the directory, so a
pre-existing Application Support or XDG path keeps its own mode.

Open the temp file with an explicit 0600 mode before any bytes are written and
rename(2) it into place. rename is atomic and carries the mode across, so the
id is never present at loose permissions, and a file left looser by an earlier
version is repaired by the next store.

This is the Swift side of the same issue fixed for the .NET client in #411,
which uses FileStreamOptions.UnixCreateMode on net8.0 and
open(O_CREAT|O_EXCL|O_CLOEXEC, 0600) on netstandard2.0.
…mask

The first version of this change shipped without a regression test, on the
reasoning that a two-syscall race is not observable in-process. That was wrong:
the earlier attempt failed because the poller walked 64 named paths per
iteration behind a Task.yield, not because the window is too narrow. Polling
contentsOfDirectory in a tight loop observes the pre-fix leak in 19 of 20 runs.

testClientIdIsNeverObservableAtLoosePermissionsDuringStore watches the
destination directory while a store runs and fails if any file in it is ever
seen carrying group or other bits. Against the previous implementation it fails
naming the leaked temp -- 'h.clientid.sb-... was 0644'. The assertion is
'nothing was ever loose', which the current implementation satisfies by
construction, so it cannot fail spuriously; a 1 MB payload widens the write
enough to make missing a real regression vanishingly unlikely.

Writing it surfaced a defect in the fix itself. open(2)'s mode argument is
masked by the process umask, so under a umask carrying 0o200 the file was
created 0400 -- read-only, where the chmod-after-write shape it replaced had
always produced exactly 0600. Adding fchmod, which is not masked, keeps the
closed window and restores the exact mode. This is what the netstandard2.0 leg
of the .NET fix in #411 does, which I had cited without matching.

testPersistedModeIsExactlyOwnerOnlyRegardlessOfUmask covers that directly: it
fails with 0400 if the fchmod is removed.
AGENTS.md: 'Do not edit CHANGELOG.md files for normal feature/fix PRs' --
changelogs are assembled from docs/.changes/ fragments by the release
maintainer per RELEASING.md. #411 does this correctly with three fragments;
this PR was editing clients/swift/CHANGELOG.md directly. Replaced with a
fragment, typed 'security' rather than 'fixed'.

@connor4312 Connor Peet (connor4312) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thanks!

auto-merge was automatically disabled August 26, 2026 23:06

Pull request was closed

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.

2 participants