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
|
continue
|
||||||
case tar.TypeSymlink:
|
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) {
|
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("restore: clear symlink %s: %w", name, err)
|
return fmt.Errorf("restore: clear symlink %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package backup
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
@@ -314,3 +318,96 @@ func TestBackupSignatureFileContent(t *testing.T) {
|
|||||||
func hexDecode(s string) ([]byte, error) {
|
func hexDecode(s string) ([]byte, error) {
|
||||||
return hex.DecodeString(s)
|
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