Skip to content

Commit 7e81e1f

Browse files
JAORMXclaude
andcommitted
Reject symlink components in InjectEnvFile
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>
1 parent aac4fab commit 7e81e1f

2 files changed

Lines changed: 44 additions & 2 deletions

File tree

hooks/hooks.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,13 @@ func InjectEnvFile(guestPath string, envMap map[string]string) func(string, *ima
192192
if err != nil {
193193
return fmt.Errorf("validate path %s: %w", guestPath, err)
194194
}
195-
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
195+
if err := image.MkdirAllNoSymlink(rootfsPath, filepath.Dir(dst), 0o755); err != nil {
196196
return fmt.Errorf("create parent dirs for %s: %w", guestPath, err)
197197
}
198-
if err := os.WriteFile(dst, []byte(buf.String()), 0o600); err != nil {
198+
if err := image.ValidateNoSymlinkLeaf(dst); err != nil {
199+
return fmt.Errorf("validate %s: %w", guestPath, err)
200+
}
201+
if err := writeFileNoFollow(dst, []byte(buf.String()), 0o600); err != nil {
199202
return fmt.Errorf("write %s: %w", guestPath, err)
200203
}
201204
return nil

hooks/hooks_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,45 @@ func TestInjectEnvFile_RejectsPathTraversal(t *testing.T) {
390390
assert.Contains(t, err.Error(), "path traversal")
391391
}
392392

393+
func TestInjectEnvFile_RejectsSymlinkComponents(t *testing.T) {
394+
t.Parallel()
395+
396+
t.Run("parent directory is a symlink", func(t *testing.T) {
397+
t.Parallel()
398+
399+
rootfs := t.TempDir()
400+
outside := t.TempDir()
401+
stageSymlink(t, rootfs, "etc", outside)
402+
403+
hook := InjectEnvFile("/etc/env", map[string]string{"FOO": "bar"})
404+
err := hook(rootfs, nil)
405+
require.Error(t, err)
406+
assert.Contains(t, err.Error(), "symlink")
407+
408+
_, statErr := os.Stat(filepath.Join(outside, "env"))
409+
assert.True(t, os.IsNotExist(statErr), "must not write under symlink target")
410+
})
411+
412+
t.Run("leaf is a symlink to a host file", func(t *testing.T) {
413+
t.Parallel()
414+
415+
rootfs := t.TempDir()
416+
require.NoError(t, os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755))
417+
418+
victim := filepath.Join(t.TempDir(), "victim")
419+
require.NoError(t, os.WriteFile(victim, []byte("original"), 0o600))
420+
require.NoError(t, os.Symlink(victim, filepath.Join(rootfs, "etc", "env")))
421+
422+
hook := InjectEnvFile("/etc/env", map[string]string{"FOO": "evil"})
423+
err := hook(rootfs, nil)
424+
require.Error(t, err)
425+
426+
got, readErr := os.ReadFile(victim)
427+
require.NoError(t, readErr)
428+
assert.Equal(t, "original", string(got), "victim must not be overwritten")
429+
})
430+
}
431+
393432
// failingChown returns a ChownFunc that returns an error when the path
394433
// ends with the given suffix, and succeeds otherwise.
395434
func failingChown(pathSuffix string) ChownFunc {

0 commit comments

Comments
 (0)