Skip to content

hooks: harden path resolution against symlink components#69

Merged
JAORMX merged 6 commits intomainfrom
jaosorior/hooks-symlink-safe
Apr 17, 2026
Merged

hooks: harden path resolution against symlink components#69
JAORMX merged 6 commits intomainfrom
jaosorior/hooks-symlink-safe

Conversation

@JAORMX
Copy link
Copy Markdown
Contributor

@JAORMX JAORMX commented Apr 17, 2026

Summary

Phase 1 of a multi-phase hardening pass on hooks/. Every Inject* helper
that writes into the rootfs now routes parent-directory creation through
image.MkdirAllNoSymlink, gates the target path with
image.ValidateNoSymlinkLeaf, and opens the final file with O_NOFOLLOW to
close the TOCTOU window. The helpers previously relied only on
pathutil.Contains, which is lexical — a rootfs whose target path contained
a symlink component had that symlink followed by the subsequent
os.MkdirAll / os.WriteFile.

Commits land one-per-change so the series is bisectable:

  • Add stageSymlink test helper for hook fixtures
  • Reject symlink components in InjectAuthorizedKeys
  • Reject symlink components in InjectFile and InjectBinary
  • Reject symlink components in InjectEnvFile
  • Guard InjectVMConfig symlink-rejection via delegation chain
  • Tighten BestEffortLchown to only swallow permission errors

BestEffortLchown previously logged then swallowed any non-permission
error; it now propagates ENOENT, EROFS, EIO, etc. so silent
ownership drift no longer hides behind a nil return.

No API break: writeFileNoFollow is unexported and used only internally.
The only observable change for callers is error propagation in
BestEffortLchown, which previously masked non-permission errors.

Test plan

  • task verify green at every commit in the series
  • New table-driven sub-tests cover: absolute symlink at home component,
    relative escaping symlink at .ssh, symlink leaf at the file target,
    parent directory as a symlink
  • Existing tests (TestInjectAuthorizedKeys_*, TestInjectFile_*,
    TestInjectEnvFile_*, TestInjectVMConfig) continue to pass
  • End-to-end verified via brood-box with a local replace directive:
    a full VM lifecycle (snapshot → boot → SSH via injected key →
    headless claude-code writing /workspace/hello.txt → flush back to
    host) completed cleanly

🤖 Generated with Claude Code

JAORMX and others added 6 commits April 16, 2026 20:16
Enables upcoming tests that verify hook helpers refuse to follow
symlink components planted in a rootfs. The helper places a symlink
at rootfs/linkPath pointing to an arbitrary target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hook previously used os.MkdirAll + os.WriteFile to place
authorized_keys under a caller-configured home path inside the
rootfs. pathutil.Contains guarded against lexical traversal but
did not inspect the resolved filesystem — a symlink component
planted inside the rootfs (e.g. by a malicious OCI layer) caused
the write to follow the link out of the rootfs.

Route the directory creation through image.MkdirAllNoSymlink
(Lstat each component, refuse symlinks), gate the leaf with
image.ValidateNoSymlinkLeaf, and open the final file with
O_NOFOLLOW to close the TOCTOU window between validation and
write.

Covered by three new sub-tests: an absolute symlink at the home
component, a relative escaping symlink at .ssh, and a symlink
leaf at authorized_keys. All assert the out-of-rootfs target is
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route parent-directory creation through image.MkdirAllNoSymlink,
gate the leaf with image.ValidateNoSymlinkLeaf, and open with
O_NOFOLLOW via writeFileNoFollow. InjectBinary delegates to
InjectFile so both are covered by the same code path.

Tests cover the two realistic rootfs shapes: a parent directory
replaced by a symlink and a leaf replaced by a symlink pointing
at a host file. Both assert the out-of-rootfs target is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last of the Inject* file-writing helpers. Same treatment as
InjectAuthorizedKeys and InjectFile: MkdirAllNoSymlink for parent
dirs, ValidateNoSymlinkLeaf for the target, O_NOFOLLOW on open.

Tests cover parent-as-symlink and leaf-as-symlink variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InjectVMConfig writes /etc/go-microvm.json by delegating to
InjectFile, which now refuses symlink components. A regression
test locks the guarantee in at the delegation boundary so a
future refactor that inlines or bypasses InjectFile cannot
silently lose the protection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, any non-permission error from os.Lchown was logged
at warn level and then silently dropped — a failure mode where
the file's owner silently ended up wrong while the caller saw
no error. Tighten the contract so only permission-denied errors
(the expected non-root case) are absorbed; everything else
(ENOENT, EROFS, EIO, ...) propagates to the caller.

Tests cover both paths: a non-existent path returning ENOENT
surfaces an error; a non-root chown to a foreign UID returning
EPERM returns nil. The EPERM test is skipped when running as
root since root can chown to any UID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JAORMX JAORMX merged commit b02826a into main Apr 17, 2026
7 checks passed
@JAORMX JAORMX deleted the jaosorior/hooks-symlink-safe branch April 17, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant