Repository navigation
fix(cron): harden usercron writeback temp file (O_EXCL, random name) - #1584
Conversation
The writeback used a fixed temp name (cronjob.toml.tmp) written with O_CREAT|O_TRUNC, so anyone able to write the directory could pre-create the file or plant a symlink and capture or redirect the write. Create the temp file with tempfile::Builder (random name, O_EXCL) in the same directory, keep the original file mode, fsync, then persist over the target. Adds tests for a planted symlink, a planted regular file, and mode preservation. Closes #1577
This comment has been minimized.
This comment has been minimized.
…avior Address review findings on #1584: - write_atomic: after persist, fsync the parent directory best-effort (unix only), matching crates/openab-mcp/src/auth.rs. - Add test_update_usercron_job_symlinked_destination_is_replaced_and_target_untouched (unix): when cronjob.toml is a symlink, its target is unchanged and the path becomes a regular file with the updated TOML. - Doc comment: state the symlinked-destination behavior and the non-goals (no locking, mode only, defense-in-depth per docs/cronjob.md). Checked: git diff --check clean; new lines ASCII-only; new code hand-formatted (cargo fmt not run, to keep pre-existing rustfmt diffs in cron.rs untouched). Not built or tested locally (low-resource host); build, clippy, and tests are verified by GitHub CI on this PR.
Silently ignoring fs::metadata errors let a writeback succeed with tempfile's default 0600 mode, breaking the mode-preservation guarantee. The only caller has just read the file, so any stat error (including NotFound from a concurrent delete) is now returned.
|
Note LGTM ✅ - All round-1 findings and the one new round-2 finding are fixed at What This PR Does
How It Works
Findings
Finding Details🟢 F1: Metadata error is no longer swallowed (round-2 finding, resolved)At
A concurrent delete or replace by a directory writer remains an accepted residual risk in the Review Contract, so this was rated Important, not Critical. Note: the round-1 summary described the old behavior ("a 🟢 F2: Round-1 findings resolved in
|
| Round-1 finding | Resolution |
|---|---|
| R1-F1: rename not durable, parent directory not fsynced | ✅ Best-effort parent-dir sync_all() after persist on unix, matching auth.rs |
| R1-F2: destination-is-a-symlink behavior untested and undocumented | ✅ Doc comment states the link is replaced and its target untouched; test_update_usercron_job_symlinked_destination_is_replaced_and_target_untouched pins it |
| R1-F3: doc comment lacked guarantees and non-goals | ✅ Non-goals listed: no locking (last-writer-wins), mode only (no owner/group/ACL/xattr), defense-in-depth relative to docs/cronjob.md |
R1-F4: ### Follow-ups: None was inaccurate |
✅ PR body now lists acp/pool.rs:383/399, acp_mcp.rs:105, notes auth.rs as the model, and defers a shared helper |
🟢 F3: Test coverage
Tests cover a planted tmp symlink (target unchanged, destination is a regular file), a planted tmp regular file (untouched, no stray temp), a symlinked destination, mode 0640 preservation, and the new missing-destination error path. Unix-only tests are #[cfg(unix)]-gated.
Considered and Not Raised
- Symlinked
cronjob.tomlbecomes a regular file - required by the Review Contract acceptance criteria; it is the safe direction (the link target is never written). - Read-modify-write race between concurrent writebacks - an explicit non-goal, unchanged from
main, and documented in the doc comment. - Other temp-file writers (
acp/pool.rs,acp_mcp.rs) - out of scope and tracked in the PR's Follow-ups.
Validation
- Head
ced815746bf3c184ca2adeb59d1630cedf5ccb70, basemain, merge-base02a8b24378e1e2dc1d5e2861525d21a05693d170, 3 commits, 1 file changed (crates/openab-core/src/cron.rs). - CI on this SHA:
checkpasses (cargo clippy,cargo clippy (unified),cargo test, and the acp test steps);Review Contract,changes,conformance,validate, andvalidate-packaged-pinspass;operatoris skipped. Docker smoke tests andbuild-builderwere still running when this summary was posted. All of them passed on the previous head4061f139, and the only change since is the error-handling fix and its test incron.rs. - There are no inline review threads on this PR.
Baseline Check
- PR opened: 2026-10-28
- Main already has: atomic temp-then-rename writeback for
cronjob.toml, but with a fixed, predictable temp name written throughstd::fs::write(follows symlinks); directory-permission guidance indocs/cronjob.md(docs(cron): drop unsafe shared-writable directory advice #1563) - Net-new value: unpredictable
O_EXCLtemp file, mode preservation with error propagation, file and parent-dir fsync, pinned symlink behavior, and regression tests
What's Good (🟢)
- Uses an existing dependency and the standard
tempfilepattern - Fails closed at every step; no fallback to a non-atomic write
- Doc comment states guarantees and non-goals explicitly
- The Review Contract maps one-to-one onto tests
5. Three Reasons We Might Not Need This PR
- The threat model already excludes it - The hole only matters if the agent can write the directory holding
cronjob.toml, whichdocs/cronjob.mdalready forbids; such a writer can replacecronjob.tomldirectly. - It narrows one vector, not a boundary - Directory write access allows many equivalent attacks (rename, squat, fill the disk), so this is defense-in-depth.
- A one-off helper can drift -
pool.rsandacp_mcp.rskeep weaker patterns until the follow-up lands a shared helper.
Counterpoint: the change is small, strictly better than main, matches auth.rs, and reason 3 is tracked as a follow-up rather than a reason to reject.
What problem does this solve?
update_usercron_jobwrites the updated usercron TOML to a fixed-name temp file (cronjob.toml.tmp) withstd::fs::write(O_CREAT|O_TRUNC, noO_EXCL/O_NOFOLLOW) and renames it over the real file. Anyone who can write the containing directory can pre-create that name or plant a symlink there, and either keep ownership of the resultingcronjob.tomlor redirect the TOML write to any file OpenAB can write. Today the only defence is the docs advice from #1563 that the directory must not be writable by others. This PR adds the code-side hardening.Closes #1577
Discord Discussion URL: Not applicable - maintainer-filed security hardening issue (#1577).
Review Contract
Goal
Make usercron writeback safe against a pre-created file or symlink at a predictable temp path in the usercron directory.
Non-goals
cronjob.tomlitself; that requires write access to the directory, which the docs already say must not be granted.Accepted Residual Risks
cronjob.tomlbetween writebacks. This is the same as before and is covered by the directory-permission guidance..cronjob.toml.*.tmpfile may be left behind. It is never reused and is harmless.Acceptance Criteria
O_EXCLin the same directory as the target (tempfile::Builder::tempfile_in).cronjob.toml.tmpis not followed: its target is unchanged andcronjob.tomlis a regular file after writeback.cronjob.toml.tmpis left untouched, and no other temp file remains after a successful writeback.cronjob.tomlis itself a symlink, writeback replaces the link with a regular file and leaves the link target unchanged (unix).update_usercron_jobtests still pass;cargo clippy --workspace -- -D warningspasses.Follow-ups
Other writers in the repo still have the same class of temp-file issue and are left for a separate change (not touched here):
crates/openab-core/src/acp/pool.rs:383and:399: fixedjson.tmptemp name written withstd::fs::writeand then renamed (the same pattern this PR fixes incron.rs).crates/openab-core/src/acp_mcp.rs:105: pid+sequence temp name opened withcreate(true).truncate(true)and noO_EXCL, so a symlink planted at the guessable name is followed.crates/openab-mcp/src/auth.rsis already hardened (create_new, mode 0600, parent-dir fsync) and is the model for the fixes above.The directory-permission guidance from #1563 remains the complementary control.
At a Glance
Prior Art & Industry Research
Not applicable - small security bug fix using the standard
tempfilecrate pattern for atomic replacement.tempfileis already a regular dependency ofopenab-core.