Harden config persistence so API keys and env secrets cannot leak - #51
Conversation
APPROVEReviewed at What I checked
A few things I confirmed beyond reading the diff, since they were the plausible ways this could break something:
Three non-blocking notes
None of these block the merge. |
| func persistApplyMode(_ *os.File, _ string, _ os.FileMode) error { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Non-blocking note 1 of 3.
Because this is a no-op on Windows, the owner-only DACL is only applied after the key has been written to the temp file. In atomicWriteConfigFile the order is: CreateTemp → persistApplyMode (nothing happens here on Windows) → Write(data) → Sync → Close → then persistLockdownNewFile or persistPreserveSecurity.
A file freshly created in %ProgramData%\Cronitor inherits that folder's ACL, and %ProgramData% normally grants BUILTIN\Users read down the tree. So for the duration of the write there is a temp file containing the API key that any local user can open. It's a narrow race — you'd need something watching the directory for change notifications — but it is avoidable: calling applyOwnerOnlyACL(tmpName) from persistApplyMode (or right after os.CreateTemp) would close it, and it would match what the Unix side already gets for free from CreateTemp's 0600.
Separately, at line 140 sddlContainsWorldAllow matches only WD (Everyone) and BU (BUILTIN\Users). An existing file whose ACL grants Authenticated Users (AU) read would pass the check and be treated as restricted enough, even though on a multi-user box that is effectively the same exposure as BU. Worth adding AU to that list.
I could not exercise either of these from Linux, so the inheritance detail is worth a sanity check on a real Windows box.
| func checkExistingConfigPerms(path string, _ os.FileInfo) error { | ||
| world, err := windowsGrantsWorldRead(path) | ||
| if err != nil { | ||
| // Fail closed: if we cannot read the ACL we will not overwrite a | ||
| // file that might be readable by other users. | ||
| return fmt.Errorf("could not inspect ACL on %s: %w\n\n%s", path, err, configMigrationGuidance) | ||
| } | ||
| if world { | ||
| return configOverlyPermissiveError(path, 0644) | ||
| } | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Non-blocking note 2 of 3.
Rejecting the file here is exactly what the spec asks for, so this is about the message rather than the behaviour. The Windows path reuses configMigrationGuidance, which is written for Unix: it names allowed modes 0600 and 0640, and its three options are "chgrp + chmod 0640", "a user-owned 0600 file", and env vars. On Windows the first two aren't actionable at all.
This matters more than it looks, because I think every existing Windows install hits it. The current release writes the config with ioutil.WriteFile(path, b, 0644), which on Windows just inherits the parent ACL — and %ProgramData% grants BUILTIN\Users read. So the first cronitor configure (or dashboard settings save) after upgrading should see BU in the DACL, reject the file, and print chgrp/chmod instructions.
Windows CI won't surface this: setup_suite.bash starts from a runner with no C:\ProgramData\Cronitor\cronitor.json, so the first write always takes the new-file path and gets a clean protected DACL.
A Windows-specific branch in the guidance text would fix it — something along the lines of "set CRONITOR_API_KEY in the service environment, or delete the existing file and re-run cronitor configure to have it recreated with an owner-only ACL", optionally mentioning icacls for operators who want to inspect it. Same fix would make the two configOverlyPermissiveError(path, 0644) calls less confusing, since 0644 is a synthetic mode here rather than something the operator can see or change.
|
|
||
| if err := ioutil.WriteFile(configPath, b, 0644); err != nil { | ||
| http.Error(w, "Failed to write config file", http.StatusInternalServerError) | ||
| if err := persistConfigFile(configPath, b); err != nil { |
There was a problem hiding this comment.
Non-blocking note 3 of 3 — follow-up, not something to change here.
Routing this write through persistConfigFile is correct and is one of the five sites the spec asks for. Flagging the surrounding handler only because it is now the widest remaining path a key takes out of the process.
SettingsResponse embeds ConfigFile, so the GET branch serialises CRONITOR_API_KEY, CRONITOR_PING_API_KEY, CRONITOR_DASH_PASS and every mcp_instances[].password to the browser in full, and further down it overwrites response.DashPassword with the live CRONITOR_DASH_PASS value. A few lines below this one, the POST branch echoes the same b it just persisted back in the response body.
All of that is pre-existing, the Settings UI reads those fields into its form inputs, and it is outside this spec — so leaving it alone is the right call for this PR. But once the CLI-side leaks are closed, an API key travelling over HTTP on port 9000 is the biggest one left, and the dashboard's auth is optional. This seems like the natural companion to the cronitor auth login work mentioned in the PR description: return presence flags plus a masked tail for display, and have the POST only accept a new value when the field actually changed.
One smaller thing in the same area: the http.Error(w, err.Error(), ...) on the next line now sends the full persist error to the client, which includes the absolute config path and the whole migration block. No secrets in it, but it is more internal detail than the previous fixed string, and this endpoint can be unauthenticated when no dashboard credentials are set.
|
Design update: we no longer reject existing 0644/0640 credential files. |
APPROVERe-reviewed at The revised behavior
Dropping Regressions of the original specNone. All ten of the original items still hold: keys and passwords are still redacted in Two details I specifically re-checked because the rewrite could plausibly have broken them:
Builds clean for Also in range but outside the spec: Three non-blocking notes
None of these block the merge. |
|
|
||
| if err := ioutil.WriteFile(configPath, b, 0644); err != nil { | ||
| http.Error(w, "Failed to write config file", http.StatusInternalServerError) | ||
| if err := persistConfigFile(configPath, b); err != nil { |
There was a problem hiding this comment.
Non-blocking note 1 of 3.
This is the one place where the new tighten-and-warn behavior loses its warning. persistConfigFile prints configAccessNarrowedWarning to the cronitor dash process's stderr, but this is an HTTP handler — the operator who just clicked Save in the Settings UI is looking at a browser, and the dash server's stderr is a terminal they walked away from, a systemd journal, or a container log. The handler then returns 200 with the config body and no indication that anything changed about who can read the file.
So the practical outcome on a machine with the usual world-readable /etc/cronitor/cronitor.json: someone edits a setting in the dashboard, the file silently becomes 0600, and every non-root cron job that was reading it starts failing with no visible connection to what they just did. That is exactly the situation the warning exists to prevent.
The CLI paths are all fine — configure and the first-run credential prompt at line 725 both print to the user's own terminal, and the BATS cases confirm the warning shows up in configure output.
Worth surfacing it in the response so the UI can show it. The smallest version is to have the persist helper hand back whether it narrowed access (a bool, or a typed warning alongside the error) instead of printing directly, then include it in this handler's JSON as something like "warning": "..." for the Settings page to render as a banner. That would also let handleSignup do the same thing, and it would stop the warning from being written into the middle of the cronitor signup Bubble Tea UI, which is the other caller where stderr is not a great destination.
| if exists { | ||
| if err := persistPreserveOwner(tmpName, existing); err != nil { | ||
| return wrapPersistWriteError(path, err) | ||
| } | ||
| } |
There was a problem hiding this comment.
Non-blocking note 2 of 3.
Preserving the previous owner is the right instinct, but making it fatal is stricter than it needs to be and it can turn a save that used to work into a hard failure.
persistPreserveOwner chowns the temp file to the existing file's uid and gid, and POSIX only lets you give a file away if you are root. So if the config file is owned by someone else and is group-writable, a non-owner who could previously update it now cannot. I confirmed both halves on this box:
-rw-rw-r-- 1 root ubuntu cronitor.json # 0664, owned by root, group ubuntu
$ printf '...' > cronitor.json # what the pre-PR ioutil.WriteFile did
plain write SUCCEEDED
$ chown 0:1000 mytemp # what persistPreserveOwner does
chown: changing ownership of 'mytemp': Operation not permitted
That EPERM propagates up through wrapPersistWriteError and aborts, so configure exits 126 with a message about the file not being writable — which is confusing, because the file plainly is writable, and the whole point of the new behavior is that this save should have tightened the file to 0600 instead of refusing.
The blast radius is limited: it needs a 0664 or 0660 credential file owned by another account, which is unusual, and the old file is left intact so there is no data loss. The common upgrade path (root saving a root-owned 0644 file, or a user saving their own --config file) chowns to values it already has and succeeds.
Since the result of skipping the chown is a file owned by the writer at mode 0600 — no worse from a secrets standpoint, arguably better — I would just let it be best-effort:
if exists {
// Best effort: keeping the previous owner is nice for shared installs,
// but a non-root writer cannot give the file away and the 0600 result
// is safe either way.
_ = persistPreserveOwner(tmpName, existing)
}If you would rather keep it strict, skipping the call when the uid and gid already match would at least avoid the pointless syscall in the common case.
| renamed = true | ||
|
|
||
| // Re-apply owner-only after replace so Windows dest ACLs cannot linger. | ||
| if err := persistLockdownNewFile(path); err != nil { | ||
| return wrapPersistWriteError(path, err) | ||
| } | ||
| return nil |
There was a problem hiding this comment.
Non-blocking note 3 of 3.
Belt-and-braces here is reasonable, especially for Windows where you do not want the destination's old security descriptor to survive the replace. The problem is only what happens when it fails: renamed is already true and the new content is on disk, but returning an error tells every caller the save did not happen.
That produces a genuinely wrong message rather than just an ugly one. cronitor signup would print "your account was created but credentials could not be saved. Sign in at cronitor.io or retry authorization" — while the keys are sitting correctly in the config file. A user following that advice would go re-authorize an account that was already set up. configure would exit 126 after having successfully written the file, and the dashboard settings save would return a 500 for a save that landed.
It is unlikely to fire — on Unix this is a chmod on a file we either own or are root for, immediately after a successful rename — but the failure mode is bad enough to be worth not returning an error from here. Two options, either is fine:
- Treat a post-commit failure as a warning rather than an error: log it or print it to stderr alongside the access-narrowed warning, and still return nil, since the write did succeed and the temp file was already locked down before the rename.
- Or return a distinguishable error so callers can say "saved, but the permissions could not be tightened" instead of "not saved" — which matters most for
formatSignupPersistError, whose whole job is telling the user what to do next.
For what it is worth, the pre-rename persistLockdownNewFile(tmpName) at line 146 already guarantees the file is owner-only at the moment it becomes visible, on both platforms, so failing the operation here buys nothing that the earlier call did not already provide.
Separately, and still non-blocking from the previous round: persistApplyMode is a no-op on Windows, so the temp file holds the API key with the parent directory's inherited ACL from the Write on line 131 until the lockdown on line 146. In %ProgramData%\Cronitor that inherited ACL normally includes BUILTIN\Users read. Applying the owner-only ACL right after os.CreateTemp would close that window and match what CreateTemp's 0600 already gives you on Unix.
3c16d76 to
455037b
Compare
Redact API and ping keys from configure output, stop dumping the environment, and persist credential files through one helper with 0600/0640 modes and atomic replace. Signup and dashboard persist errors no longer include returned keys. Co-authored-by: August Flanagan <aflanagan@users.noreply.github.com>
Co-authored-by: August Flanagan <aflanagan@users.noreply.github.com>
A save no longer aborts because the old credential file was world-readable. The write still succeeds atomically as owner-only (0600 / Windows owner ACL) and prints a warning that other users lose read access unless keys are injected in the job environment. Co-authored-by: August Flanagan <aflanagan@users.noreply.github.com>
New credential files are still created owner-only. An existing file now keeps its current Unix mode or Windows DACL across a save instead of being narrowed to 0600, so upgrading never changes who can read a config that non-root cron jobs depend on. The narrowing warning and its tests go with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
--restrict makes an existing config file owner-only on request. Without it, a save that leaves the file readable by other users prints a note on stderr naming the flag and what jobs running as another user need if the operator tightens the file. The notice never changes the file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
ReadInConfig errors were swallowed, so a job whose config file had been made unreadable ran with no API key, hostname, or environment and nothing said so. A missing file stays silent; any other read error is reported once on stderr with the path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
The configure tests left a fake API key in viper, which lets a later test's replayed root command reach the real API. Reset the credential keys in cleanup. The exec duration assertion now matches the version on the integrations branch: Linux keeps the strict 2s bound and only Windows is widened, so the two PRs merge without conflict. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
The README described the old behavior of rewriting existing files as 0600 with a warning. Describe what the CLI now does: new files are owner-only, existing files keep their permissions, --restrict tightens on request, and an unreadable config file is reported at startup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
Older releases wrote new config files world-readable, so a fresh install on a host where jobs run as other users worked without extra steps. New files are now 0600, which those jobs cannot read. Say so on stderr at creation time, with the env-var, per-user-file, and chmod options, instead of leaving it to a failing job to surface. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
A non-root user rewriting a group- or world-writable config file it does not own gets EPERM from chown, which failed the whole save. The in-place write this replaces used to succeed. Treat a permission error from chown as non-fatal; the replacement file is then owned by the writer with the original mode preserved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
455037b to
f284709
Compare
…rite Replacing an existing file through rename could not preserve group ownership when the writer is not root, and it dropped Unix ACLs and extended attributes because the inode changed. Rewrite existing files through their own inode, as ioutil.WriteFile did before, so owner, group, mode, and ACLs are untouched by a save. Only --restrict changes access, and it does so in place after the write. New files still go through an owner-only temp file and an atomic rename. On Windows, apply the owner-only DACL to the temp file right after CreateTemp, before any secret is written, instead of after close. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
saveSignupCredentials rebuilt the file from the ConfigFile struct, which drops any key the struct does not model. viper.WriteConfig, which it replaced, kept them. Read the existing JSON, set the two keys, and write the merged object back. Fall back to the struct only when there is no file or it does not parse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
The unreadable-config warning included the parser's diagnostic. For formats that quote the offending line, that can print a stored secret to stderr on every command. Report the path plus one of three reasons: permission denied, file could not be opened, or file is not valid JSON. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
Writing in place made every save non-atomic: a failed write left a truncated file and concurrent readers could see partial JSON. Go back to staging in a sibling temp file and renaming it over the destination, and make the temp carry the existing file's access before the secret is written: mode bits; owner, or at least the group when the writer cannot give the file away, refusing the save if a group others depend on cannot be kept; extended attributes, which is where Linux stores POSIX ACLs; and on macOS the temp is created with clonefile so the ACL comes along. Windows copies the DACL. A new file, or --restrict, gets a bare owner-only temp with nothing cloned, so --restrict now drops ACL grants as well as mode bits. A test fails the data write itself and checks the original file is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
Files written by viper.WriteConfig in older releases use lowercase names. Adding the uppercase keys next to them left two copies, and a decoder could pick the stale one. Remove any case-insensitive match for the two credential keys before writing the new values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
A temp file created in a directory with a default ACL inherits an access ACL. Copying the original's xattrs cannot remove it when the original has none, so a save could grant another user read access the operator never gave. The same inherited grant survived on new files and on --restrict. Handle the access ACL by name on Linux (system.posix_acl_access) and macOS (com.apple.system.Security, which listxattr does not report): copy it when the original has one, remove it when it does not, and read it back to confirm the result matches. A mismatch fails the save rather than silently changing who can read the credentials. The owner-only path clears any inherited ACL the same way. The macOS clonefile attempt is gone; it did not carry the source ACL. Tests use a real default ACL on the temp directory and confirm that a saved 0640 file, a new file, and a --restrict file carry no inherited grant, and that an existing access ACL survives byte for byte. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
persistCloneXattrs and the ACL helpers were compiled only for linux and darwin but called on every Unix target, so GOOS=freebsd failed to build. FreeBSD is in the release matrix. x/sys emulates the xattr interface over extattr there, so the xattr copy now builds for it; ACLs are not reachable through that interface, so the ACL sync is a documented no-op on FreeBSD and other Unix targets. All four release targets vet clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
The generic xattr calls against com.apple.system.Security return EPERM on ordinary files, so every save failed on macOS. Use the interface Apple's own acl_get_file and acl_set_file are built on instead: getattrlist and setattrlist with ATTR_CMN_EXTENDED_SECURITY, which exchange the file's kauth_filesec blob through an attrreference. The release build has CGO_ENABLED=0, so the read side goes through syscall.Syscall6 with SYS_GETATTRLIST; x/sys exports Setattrlist for the write side. The shared sync and clear logic now sits over three primitives (readACL, writeACL, removeACL) with Linux and macOS implementations. Clearing an ACL first tries a zero-length attribute and falls back to a filesec carrying KAUTH_FILESEC_NOACL; the caller verifies either way. A file without an ACL makes no modifying call, so the common save path only reads. The attrreference encode and decode are pure functions with unit tests that run on every platform, and a darwin-only test file exercises the real thing with chmod +a and ls -le. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
aflanagan
left a comment
There was a problem hiding this comment.
Verified on an Apple Silicon Mac: the macOS persistence blocker is fixed in 85dddbb. Plain-file saves, explicit ACL preservation/restriction, and independently checked inherited ACL handling pass. Full Go and race suites pass, and a real configure invocation exits 0. One test coverage correction remains below; no remaining functional blocker was found in this macOS change.
ls -le prints an inherited entry as "everyone inherited allow read", so a plain "everyone allow read" match skipped the inheritance test and could have missed a leaked inherited grant in the assertions. Match either form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo
Stop displaying Cronitor API keys, ping-auth keys, dashboard credentials, and unrelated environment secrets through
configureoutput, verbose dumps, signup errors, and new config files. Supported CLI flags and default paths are unchanged. Existing config files keep their permissions, group, and ACLs.What changed
cronitor configureprintsAPI Key: Set/Ping API Key: Set(orNot Set) instead of complete keys. Dashboard passwords stay********. MCP instance passwords are not printed.--api-key,--ping-api-key, and--dash-passwordremain for compatibility; help and README warn that they appear in shell history and process lists. Prefer env vars.os.Environ()dump under--verboseis gone. Verbose output lists an allowlist of Cronitor variable names withSet/Not Set, never values.api_key/ping_api_key. Signup merges the two keys into the existing file's JSON, removing any case variant of them first, so settings outside theConfigFilestruct survive and no stale lowercase copy remains.persistConfigFile:configure,signup, and the three dashboard save paths. Unrelated 0644 writes (debug logs, crontabs,--outputfiles) are unchanged.File permissions
Every save stages in a sibling temp file and renames it over the destination. Readers see the old file or the new one, never a partial one, and a failed write leaves the old file intact.
system.posix_acl_access; macOS usesgetattrlist/setattrlistwithATTR_CMN_EXTENDED_SECURITY, the interfaceacl_get_file/acl_set_fileare built on; Windows copies the DACL. A0644system file stays0644; aroot:shared 0660file saved by a group member keeps its group and mode. Upgrading never changes who can read the config.0600on Unix with any inherited ACL removed, a protected owner-only DACL on Windows applied before the first byte is written.configureprints a note at creation saying other users cannot read the file, with the env-var, per-user-file, and chmod options.cronitor configure --restricttakes the new-file path for an existing file: mode bits and ACL grants are both dropped.configureprints a note on stderr naming--restrict. The note never changes the file.permission denied,file could not be opened,file is not valid JSON), never the parser's text. A missing file stays silent.Verified scenarios
Linux, real binary as root and as
nobody:0644file0644, content merged,nobodystill reads itnobody) saves aroot:nogroup 0660file carrying an xattr, in a2770dirnobody:nogroup 0660, xattr intact, content updated0640file with no ACL, in a directory whose default ACL grantsnobodyread0640with no ACL;nobodycannot read before or after0600, no inherited ACL;nobodycannot read--restricton a file carrying a grant0600, grant gone0600, creation notice on stderr, no key in outputmacOS,
go test ./cmd -run TestDarwinACL -vrun by the reviewer: a plain file reads as "no ACL" and saves; an expliciteveryone allow readgrant survives a normal save and is dropped by--restrict; afile_inheritdirectory grant is not added to a saved or new file.Tests
Go tests in
cmd/config_persist_test.go,cmd/config_persist_unix_test.go,cmd/config_persist_linux_test.go(real POSIX default ACL on the temp dir),cmd/config_persist_attrlist_test.go(attrreference layout, all platforms),cmd/config_persist_darwin_test.go,cmd/config_persist_windows_test.go, andcmd/configure_secrets_test.go; bats cases intests/test-configure.bats. All fixtures use*-not-realstrings.GOOS=linux|darwin|freebsd|windows go vet ./cmd/all pass. Rebased on master with #52 merged.🤖 Generated with Claude Code
https://claude.ai/code/session_0134bj2HYgae8W9cxdLicpeo