Banked: 2026-07-15, cycle358 (consul-template CVE-2026-14361 incomplete-fix analysis).
A common "symlink-safe file write" fix pattern is: Lstat(parentDir) reject-if-symlink + Lstat(finalComponent) reject-if-symlink + open(..., O_NOFOLLOW). This is INCOMPLETE:
- O_NOFOLLOW only refuses a symlink AS THE FINAL PATH COMPONENT. It does NOT prevent the kernel from following symlinks in INTERMEDIATE/ANCESTOR path components during open's path resolution. So a symlink at /a (ancestor) in open("/a/b/c", O_NOFOLLOW) is still followed; only c being a symlink is refused.
- The Lstat pre-checks are TOCTOU-racy for the parent/ancestors. O_NOFOLLOW closes the race for the final component only; the parent-dir can be swapped to a symlink between Lstat(parent) and open -> the open follows it.
- Checking "every ancestor" manually is rejected by maintainers because it false-positives on OS-managed symlinks (/var -> /private/var on macOS). (consul-template's fix comment says exactly this.)
openat2 with RESOLVE_NO_SYMLINKS (Linux) or Go os.Root / os.OpenInRoot / os.OpenRoot (Go 1.24+): resolves + validates the WHOLE path atomically inside a root, rejecting symlink-escape at ANY level with NO TOCTOU, and WITHOUT the OS-symlink false-positive problem (os.Root permits in-root symlinks, rejects escapes). Same structural fix Forgejo used (os.Root) for its template-symlink CVE. This is now the go-to reference remediation for symlink-safe file writes in Go.
When you see a symlink-safe-write fix using Lstat + O_NOFOLLOW (not os.Root/openat2), it is INCOMPLETE by construction: ancestor-symlink-blind + parent-TOCTOU. Test writeToFile-style sinks: (a) ancestor symlink 2+ levels up (redirects, passes the direct-parent check); (b) parent-dir race-swap (O_NOFOLLOW doesn't guard intermediate traversal). The complete implementations use os.Root/openat2. grep for O_NOFOLLOW/openNoFollow WITHOUT openat2/os.OpenRoot = the partial pattern.
consul-template's residual is LOW/local + one part documented-by-design + HashiCorp stated "no sandbox boundary" threat-model -> operator-decision hardening note, not a clean cash stage. But the "O_NOFOLLOW-only-guards-final-component" correctness point is precise + reusable across ANY Go project's symlink-safe-write.