fix: Lstat in Mkdir rejects symlink-to-dir #40
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
mod-nag
mod-nag
mod-nag
mod-nag/ignore
mod-nag/ignore
mod-nag/ignore
nagonag
nagonag/ignore
question
security
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
heiko/cert-proxy!40
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/36-mkdir-lstat-symlink"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #36.
Summary
os.Statin the EEXIST fallback follows symlinks: a symlink planted at a cert directory path betweenos.Mkdirfailing and the stat check was silently accepted, redirecting subsequent cert/key writes to the symlink target.os.Lstat; symlinks failIsDir()and the outer EEXIST error is returned instead.Test plan
go test ./internal/shared/ -run TestMkdir—TestMkdir_SymlinkToDirfails on master, passes hereCode review
What it does
Fixes a symlink-substitution vulnerability in
internal/shared/Mkdir. The EEXIST fallback usedos.Stat(symlink-following), so a symlink planted at a cert directory path between the failedos.Mkdirand the stat check would be accepted as a real directory, redirecting all subsequent cert/key writes to the attacker's chosen target. Swapping toos.Lstatcloses that path:stat.IsDir()returns false for a symlink and the outer EEXIST error is surfaced instead.Correctness
Lstaton a symlink returnsModeSymlink;IsDir()is false; control falls through toreturn errwhich carries the original EEXIST. All fourMkdirtests pass.errfromLstatis local; the outererr(EEXIST) is what gets returned. Do not flatten this.os.Mkdirandos.Lstat. However, the worst-case outcome post-fix is an error (not a redirect), so it is not a regression. The issue asks only for symlink defence, not full TOCTOU elimination.Style & conventions
Test coverage
TestMkdir_SymlinkToDircovers exactly the vulnerability: real directory, symlink pointing at it,Mkdiron the symlink path must error.Mkdirtests still pass — no regressions.Minor observations (not blocking, all pre-existing)
os.IsExistis deprecated since Go 1.16;errors.Is(err, fs.ErrExist)is preferred. Out of scope.TestMkdir_Idempotentsilently discards theos.Mkdirerror (_ = os.Mkdir(...)), inconsistent with file style. Out of scope.0777onos.Mkdiris tracked as #28 — correctly left untouched.Verdict
Ready to merge.
— 🤖 Generated with Claude Code (claude-sonnet-4-6)
Opus security review
Fix correctness
Correct for the stated vulnerability.
Lstatreturns metadata for the symlink itself;IsDir()is false on a symlink even when the target is a directory. The fall-through toreturn erron line 23 returns the outer EEXIST — the variable shadowing is load-bearing, not a bug. The benign idempotent case (real directory pre-exists) still returns nil.Remaining attack surface
writeFilehas the same vulnerability class — more dangerous now that the directory door is closed.cmd/cert-proxy-client/cert/cert.go:319–344:os.OpenFilewithoutO_NOFOLLOWfollows symlinks on Linux. The temp filename is<artifact>-<unix_ts>.pemwhere the timestamp istime.Now().Unix()— predictable to the second. An attacker with write access to<certbase>/<domain>(same precondition as #36) can pre-plant a symlink at the predicted filename. TheO_WRONLY|O_TRUNCwrite then redirects private key material to an attacker-chosen path. The subsequentfile.Chmod(0600)operates on the open fd, giving the attacker a chmod primitive on any path the client can reach.Fix: add
syscall.O_NOFOLLOWto theOpenFileflags. Warrants a separate issue at the same severity as #36.Mode
0777onos.Mkdir(line 10)Directories holding private keys should use
0700at source. Umask may save you in practice, but the intent is wrong. Already tracked as #28.Test adequacy
Sufficient — pins the exact regression. Dangling-symlink and symlink-to-file cases are nice-to-haves, not blockers.
Verdict
Merge this fix. File a P1 followup for
writeFile/O_NOFOLLOW— same threat model, arguably worse because a predictable second-resolution timestamp gives the attacker a concrete per-file redirect path now that the directory redirect is closed.— 🤖 Opus review via Claude Code (claude-opus-4-7)
PASS: go test ./... passed.
Review findings
master. Forgejo reports the PR as not mergeable, and the currentmaster..pr-40diff removes thestatically-linked-binaryoverride lines from bothdebian/cert-proxy-client.lintian-overridesanddebian/cert-proxy-server.lintian-overrides. Those lines were added by the currentmasterpackaging commit (1146618), so merging this PR as-is would partly undo that packaging fix. Rebase or mergemasterand keep the lintian override files unchanged unless the static-linking override removal is intentional and explained.No new security or bug issue opened from this pass. The symlink/temp-file write issue noted in the earlier review is already tracked as #35, and this PR is the fix for #36. The directory mode concern remains #28.
Verification:
env GOCACHE=/tmp/cert-proxy-go-build CCACHE_DIR=/tmp/cert-proxy-ccache go test ./internal/shared/passed.(co)authored by ai:gpt-5-codex
PASS: go test ./... passed.
PASS: go test ./... passed.