@@ -93,6 +93,24 @@ func TestInjectVMConfig(t *testing.T) {
9393 }
9494}
9595
96+ func TestInjectVMConfig_RejectsSymlinkComponents (t * testing.T ) {
97+ t .Parallel ()
98+
99+ // Guard the delegation chain: InjectVMConfig -> InjectFile.
100+ // If InjectFile's symlink safety regresses, this test catches it.
101+ rootfs := t .TempDir ()
102+ outside := t .TempDir ()
103+ stageSymlink (t , rootfs , "etc" , outside )
104+
105+ hook := InjectVMConfig (vmconfig.Config {TmpSizeMiB : 512 })
106+ err := hook (rootfs , nil )
107+ require .Error (t , err )
108+ assert .Contains (t , err .Error (), "symlink" )
109+
110+ _ , statErr := os .Stat (filepath .Join (outside , "go-microvm.json" ))
111+ assert .True (t , os .IsNotExist (statErr ), "must not write under symlink target" )
112+ }
113+
96114func TestInjectFile_WritesContent (t * testing.T ) {
97115 t .Parallel ()
98116
@@ -325,6 +343,61 @@ func TestInjectBinary_RejectsPathTraversal(t *testing.T) {
325343 assert .Contains (t , err .Error (), "path traversal" )
326344}
327345
346+ func TestInjectFile_RejectsSymlinkComponents (t * testing.T ) {
347+ t .Parallel ()
348+
349+ t .Run ("parent directory is a symlink" , func (t * testing.T ) {
350+ t .Parallel ()
351+
352+ rootfs := t .TempDir ()
353+ outside := t .TempDir ()
354+ stageSymlink (t , rootfs , "etc" , outside )
355+
356+ hook := InjectFile ("/etc/myconfig.txt" , []byte ("hello" ), 0o644 )
357+ err := hook (rootfs , nil )
358+ require .Error (t , err )
359+ assert .Contains (t , err .Error (), "symlink" )
360+
361+ _ , statErr := os .Stat (filepath .Join (outside , "myconfig.txt" ))
362+ assert .True (t , os .IsNotExist (statErr ), "must not write under symlink target" )
363+ })
364+
365+ t .Run ("leaf is a symlink to a host file" , func (t * testing.T ) {
366+ t .Parallel ()
367+
368+ rootfs := t .TempDir ()
369+ require .NoError (t , os .MkdirAll (filepath .Join (rootfs , "etc" ), 0o755 ))
370+
371+ victim := filepath .Join (t .TempDir (), "victim" )
372+ require .NoError (t , os .WriteFile (victim , []byte ("original" ), 0o600 ))
373+ require .NoError (t , os .Symlink (victim , filepath .Join (rootfs , "etc" , "myconfig.txt" )))
374+
375+ hook := InjectFile ("/etc/myconfig.txt" , []byte ("evil" ), 0o644 )
376+ err := hook (rootfs , nil )
377+ require .Error (t , err )
378+
379+ got , readErr := os .ReadFile (victim )
380+ require .NoError (t , readErr )
381+ assert .Equal (t , "original" , string (got ), "victim must not be overwritten" )
382+ })
383+ }
384+
385+ func TestInjectBinary_RejectsSymlinkComponents (t * testing.T ) {
386+ t .Parallel ()
387+
388+ rootfs := t .TempDir ()
389+ outside := t .TempDir ()
390+ stageSymlink (t , rootfs , "usr" , outside )
391+
392+ hook := InjectBinary ("/usr/bin/mytool" , []byte ("#!/bin/sh\n echo hi" ))
393+ err := hook (rootfs , nil )
394+ require .Error (t , err )
395+ assert .Contains (t , err .Error (), "symlink" )
396+
397+ _ , statErr := os .Stat (filepath .Join (outside , "bin" , "mytool" ))
398+ assert .True (t , os .IsNotExist (statErr ), "must not write under symlink target" )
399+ }
400+
328401func TestInjectEnvFile_RejectsPathTraversal (t * testing.T ) {
329402 t .Parallel ()
330403
@@ -335,6 +408,45 @@ func TestInjectEnvFile_RejectsPathTraversal(t *testing.T) {
335408 assert .Contains (t , err .Error (), "path traversal" )
336409}
337410
411+ func TestInjectEnvFile_RejectsSymlinkComponents (t * testing.T ) {
412+ t .Parallel ()
413+
414+ t .Run ("parent directory is a symlink" , func (t * testing.T ) {
415+ t .Parallel ()
416+
417+ rootfs := t .TempDir ()
418+ outside := t .TempDir ()
419+ stageSymlink (t , rootfs , "etc" , outside )
420+
421+ hook := InjectEnvFile ("/etc/env" , map [string ]string {"FOO" : "bar" })
422+ err := hook (rootfs , nil )
423+ require .Error (t , err )
424+ assert .Contains (t , err .Error (), "symlink" )
425+
426+ _ , statErr := os .Stat (filepath .Join (outside , "env" ))
427+ assert .True (t , os .IsNotExist (statErr ), "must not write under symlink target" )
428+ })
429+
430+ t .Run ("leaf is a symlink to a host file" , func (t * testing.T ) {
431+ t .Parallel ()
432+
433+ rootfs := t .TempDir ()
434+ require .NoError (t , os .MkdirAll (filepath .Join (rootfs , "etc" ), 0o755 ))
435+
436+ victim := filepath .Join (t .TempDir (), "victim" )
437+ require .NoError (t , os .WriteFile (victim , []byte ("original" ), 0o600 ))
438+ require .NoError (t , os .Symlink (victim , filepath .Join (rootfs , "etc" , "env" )))
439+
440+ hook := InjectEnvFile ("/etc/env" , map [string ]string {"FOO" : "evil" })
441+ err := hook (rootfs , nil )
442+ require .Error (t , err )
443+
444+ got , readErr := os .ReadFile (victim )
445+ require .NoError (t , readErr )
446+ assert .Equal (t , "original" , string (got ), "victim must not be overwritten" )
447+ })
448+ }
449+
338450// failingChown returns a ChownFunc that returns an error when the path
339451// ends with the given suffix, and succeeds otherwise.
340452func failingChown (pathSuffix string ) ChownFunc {
@@ -427,6 +539,70 @@ func TestInjectAuthorizedKeys_RejectsPathTraversal(t *testing.T) {
427539 assert .Contains (t , err .Error (), "path traversal" )
428540}
429541
542+ func TestInjectAuthorizedKeys_RejectsSymlinkComponents (t * testing.T ) {
543+ t .Parallel ()
544+
545+ chown , _ := recordingChown ()
546+ pubKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAATEST test@example.com"
547+
548+ t .Run ("home component is an absolute symlink out of rootfs" , func (t * testing.T ) {
549+ t .Parallel ()
550+
551+ rootfs := t .TempDir ()
552+ outside := t .TempDir ()
553+ stageSymlink (t , rootfs , "home" , outside )
554+
555+ hook := InjectAuthorizedKeys (pubKey , WithChown (chown ))
556+ err := hook (rootfs , nil )
557+ require .Error (t , err )
558+ assert .Contains (t , err .Error (), "symlink" )
559+
560+ // Nothing must have been written under the symlink target.
561+ _ , statErr := os .Stat (filepath .Join (outside , "sandbox" , ".ssh" , "authorized_keys" ))
562+ assert .True (t , os .IsNotExist (statErr ), "must not write under symlink target" )
563+ })
564+
565+ t .Run ("dot-ssh component is a relative escaping symlink" , func (t * testing.T ) {
566+ t .Parallel ()
567+
568+ rootfs := t .TempDir ()
569+ require .NoError (t , os .MkdirAll (filepath .Join (rootfs , "home" , "sandbox" ), 0o755 ))
570+ // .ssh points two levels up, escaping the rootfs lexically after resolution.
571+ outside := t .TempDir ()
572+ stageSymlink (t , rootfs , "home/sandbox/.ssh" , outside )
573+
574+ hook := InjectAuthorizedKeys (pubKey , WithChown (chown ))
575+ err := hook (rootfs , nil )
576+ require .Error (t , err )
577+ assert .Contains (t , err .Error (), "symlink" )
578+
579+ _ , statErr := os .Stat (filepath .Join (outside , "authorized_keys" ))
580+ assert .True (t , os .IsNotExist (statErr ), "must not write under symlink target" )
581+ })
582+
583+ t .Run ("authorized_keys leaf is a symlink to a host file" , func (t * testing.T ) {
584+ t .Parallel ()
585+
586+ rootfs := t .TempDir ()
587+ sshDir := filepath .Join (rootfs , "home" , "sandbox" , ".ssh" )
588+ require .NoError (t , os .MkdirAll (sshDir , 0o700 ))
589+
590+ // An attacker-planted symlink at the leaf points to an arbitrary host file.
591+ victim := filepath .Join (t .TempDir (), "victim" )
592+ require .NoError (t , os .WriteFile (victim , []byte ("original" ), 0o600 ))
593+ require .NoError (t , os .Symlink (victim , filepath .Join (sshDir , "authorized_keys" )))
594+
595+ hook := InjectAuthorizedKeys (pubKey , WithChown (chown ))
596+ err := hook (rootfs , nil )
597+ require .Error (t , err )
598+
599+ // The host-side file must be untouched.
600+ got , readErr := os .ReadFile (victim )
601+ require .NoError (t , readErr )
602+ assert .Equal (t , "original" , string (got ), "victim must not be overwritten" )
603+ })
604+ }
605+
430606func TestInjectEnvFile_RejectsInvalidKeyNames (t * testing.T ) {
431607 t .Parallel ()
432608
@@ -453,6 +629,63 @@ func TestInjectEnvFile_RejectsInvalidKeyNames(t *testing.T) {
453629 }
454630}
455631
632+ // stageSymlink places a symlink at rootfs/linkPath pointing to target.
633+ // Parent directories of linkPath inside rootfs are created as 0o755.
634+ // Use this to build rootfs fixtures that exercise symlink-following behavior
635+ // in hook code — e.g. a malicious layer shipping `home/sandbox/.ssh` as a
636+ // symlink to somewhere outside the rootfs.
637+ func stageSymlink (t * testing.T , rootfs , linkPath , target string ) {
638+ t .Helper ()
639+ abs := filepath .Join (rootfs , linkPath )
640+ require .NoError (t , os .MkdirAll (filepath .Dir (abs ), 0o755 ))
641+ require .NoError (t , os .Symlink (target , abs ))
642+ }
643+
644+ func TestStageSymlink (t * testing.T ) {
645+ t .Parallel ()
646+
647+ rootfs := t .TempDir ()
648+ outside := t .TempDir ()
649+
650+ stageSymlink (t , rootfs , "home/sandbox/.ssh" , outside )
651+
652+ info , err := os .Lstat (filepath .Join (rootfs , "home" , "sandbox" , ".ssh" ))
653+ require .NoError (t , err )
654+ assert .NotZero (t , info .Mode ()& os .ModeSymlink , ".ssh should be a symlink" )
655+
656+ dest , err := os .Readlink (filepath .Join (rootfs , "home" , "sandbox" , ".ssh" ))
657+ require .NoError (t , err )
658+ assert .Equal (t , outside , dest )
659+ }
660+
661+ func TestBestEffortLchown_PropagatesNonPermissionErrors (t * testing.T ) {
662+ t .Parallel ()
663+
664+ // ENOENT from a non-existent path is not a permission error; the function
665+ // must return it rather than silently swallowing.
666+ missing := filepath .Join (t .TempDir (), "does-not-exist" )
667+ err := BestEffortLchown (missing , 1000 , 1000 )
668+ require .Error (t , err )
669+ assert .Contains (t , err .Error (), "lchown" )
670+ }
671+
672+ func TestBestEffortLchown_SwallowsPermissionErrors (t * testing.T ) {
673+ t .Parallel ()
674+
675+ if os .Geteuid () == 0 {
676+ t .Skip ("requires non-root: root can chown to any UID, so no EPERM" )
677+ }
678+
679+ // Create a file we own; chowning to a UID we don't own should fail with
680+ // EPERM on Linux and macOS as a non-root user. BestEffortLchown must
681+ // swallow that specific error.
682+ target := filepath .Join (t .TempDir (), "target" )
683+ require .NoError (t , os .WriteFile (target , []byte ("x" ), 0o600 ))
684+
685+ err := BestEffortLchown (target , 1 , 1 )
686+ require .NoError (t , err , "permission error must be swallowed, got: %v" , err )
687+ }
688+
456689func TestShellEscape (t * testing.T ) {
457690 t .Parallel ()
458691
0 commit comments