fix(P12): backup symlink validation (REQ-127, F7)
---ci--- project: orca phase: 12 milestone: v0.12 status: execute ---/ci--- Restore validates Linkname: rejects absolute, .. traversal, and links escaping target dir. Prevents symlink-to-/etc/shadow attacks. 2 regression tests with crafted tarballs. Build + vet green.
This commit is contained in:
@@ -310,6 +310,26 @@ func Restore(opts RestoreOptions) error {
|
||||
}
|
||||
continue
|
||||
case tar.TypeSymlink:
|
||||
// REQ-127 / F7: validate Linkname to prevent symlink attacks.
|
||||
// Reject absolute links, .. traversal, and links outside
|
||||
// the target dir (which could point to /etc/shadow etc.).
|
||||
link := hdr.Linkname
|
||||
if link == "" {
|
||||
return fmt.Errorf("restore: empty symlink linkname for %q", name)
|
||||
}
|
||||
if strings.HasPrefix(link, "/") {
|
||||
return fmt.Errorf("restore: symlink %q has absolute linkname %q (REQ-127: path traversal)", name, link)
|
||||
}
|
||||
if strings.Contains(link, "..") {
|
||||
// Resolve the link relative to the dest dir; if it
|
||||
// escapes the target, reject.
|
||||
linkDest := filepath.Join(filepath.Dir(dest), link)
|
||||
linkClean := filepath.Clean(linkDest)
|
||||
targetClean := filepath.Clean(target)
|
||||
if !strings.HasPrefix(linkClean, targetClean+string(filepath.Separator)) && linkClean != targetClean {
|
||||
return fmt.Errorf("restore: symlink %q linkname %q escapes target (REQ-127)", name, link)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("restore: clear symlink %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -314,3 +318,96 @@ func TestBackupSignatureFileContent(t *testing.T) {
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
return hex.DecodeString(s)
|
||||
}
|
||||
|
||||
// --- REQ-127 / F7 backup symlink validation tests ---
|
||||
|
||||
// TestRestoreRejectsAbsoluteSymlink verifies a tarball with an absolute
|
||||
// symlink linkname is rejected.
|
||||
func TestRestoreRejectsAbsoluteSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a crafted tarball with an absolute symlink.
|
||||
tarPath := filepath.Join(dir, "evil.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarball(tarPath, "link", "/etc/shadow"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
// Create a valid signature (the signature verifies, but the symlink
|
||||
// validation should still reject the restore).
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject absolute symlink (REQ-127)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absolute") {
|
||||
t.Errorf("error should mention absolute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreRejectsTraversalSymlink verifies a tarball with a .. symlink
|
||||
// that escapes the target is rejected.
|
||||
func TestRestoreRejectsTraversalSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tarPath := filepath.Join(dir, "evil2.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarball(tarPath, "link", "../../etc/shadow"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore2")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject traversal symlink (REQ-127)")
|
||||
}
|
||||
}
|
||||
|
||||
// createCraftedTarball creates a tar.gz containing a single symlink
|
||||
// entry with the given linkname. Used to test symlink validation.
|
||||
func createCraftedTarball(path, name, linkname string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: linkname,
|
||||
Mode: 0o644,
|
||||
}
|
||||
return tw.WriteHeader(hdr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user