fix(swift): create the client-id file with owner-only permissions - #413
Closed
Josh (joshmouch) wants to merge 3 commits into
Closed
fix(swift): create the client-id file with owner-only permissions#413Josh (joshmouch) wants to merge 3 commits into
Josh (joshmouch) wants to merge 3 commits into
Conversation
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'.
Connor Peet (connor4312)
enabled auto-merge
August 26, 2026 21:53
auto-merge was automatically disabled
August 26, 2026 23:06
Pull request was closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FileClientIdStorewrote the client id and only then restricted the file: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
ensureDirectory()applies0700only 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,
fchmodit to pin the mode, thenrename(2)it into place: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
openwith a mode andfchmod, 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:open(0600)aloneopen+fchmodfchmodis 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 thefchmodis removed. Also asserts the value reads back, since a file the owner cannot read is not a fix.testFileIsRestrictedToOwnerWhenPossiblewas wrapped inif let attrs = try? …with noelse, 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
### Securityheading to the Swift changelog. The file uses Added/Changed/Fixed/Removed;Securityis a standard Keep a Changelog category, but say the word and I'll fold it into### Fixed.Package.swift, so noGlibcconditional is needed.