Skip to content

Commit c264953

Browse files
committed
fix(utils): reject tar hardlinks that escape the extraction root
ExtractArchive pre-scans archive members and rejects symlinks, but tar hardlink entries carry a regular file mode and so pass that check. Header.Linkname was never validated, so an archive could create a link to a path outside the destination directory. Validate Linkname with the same path check already applied to member names. Hardlinks that resolve inside the extraction root still extract, so ordinary archives are unaffected. pkg/oci/image.go already resolves tar.TypeLink targets before using them; this brings the archive extraction path in line with it. Assisted-by: Claude:claude-opus-5 Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>
1 parent c089caf commit c264953

2 files changed

Lines changed: 57 additions & 0 deletions

File tree

pkg/utils/untar.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ func ExtractArchive(archive, dst string) error {
7070
if f.FileInfo.Mode()&os.ModeSymlink != 0 {
7171
return fmt.Errorf("archive contains a symlink")
7272
}
73+
if linkname, ok := archiveMemberLinkname(f); ok {
74+
if err := validateArchiveMemberPath(extractRoot, linkname); err != nil {
75+
return err
76+
}
77+
}
7378
return nil
7479
})
7580

@@ -95,6 +100,18 @@ func archiveMemberName(f archiver.File) string {
95100
}
96101
}
97102

103+
// archiveMemberLinkname reports the target of a tar hardlink member, which carries a regular file mode and so is not caught by the symlink check.
104+
func archiveMemberLinkname(f archiver.File) (string, bool) {
105+
switch h := f.Header.(type) {
106+
case tar.Header:
107+
return h.Linkname, h.Typeflag == tar.TypeLink
108+
case *tar.Header:
109+
return h.Linkname, h.Typeflag == tar.TypeLink
110+
default:
111+
return "", false
112+
}
113+
}
114+
98115
func validateArchiveMemberPath(root, name string) error {
99116
if name == "" {
100117
return fmt.Errorf("archive contains an empty path")

pkg/utils/untar_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ var _ = Describe("utils/archive tests", func() {
5959
Expect(err.Error()).To(ContainSubstring("unsafe path"))
6060
Expect(filepath.Join(tmpDir, "escaped.txt")).ToNot(BeAnExistingFile())
6161
})
62+
63+
It("rejects tar hardlinks that point outside the destination", func() {
64+
tmpDir := GinkgoT().TempDir()
65+
archivePath := filepath.Join(tmpDir, "model.tar")
66+
extractPath := filepath.Join(tmpDir, "models")
67+
68+
Expect(writeTarArchiveWithHardlink(archivePath, "payload.bin", "../../escaped.txt")).To(Succeed())
69+
70+
err := ExtractArchive(archivePath, extractPath)
71+
72+
Expect(err).To(HaveOccurred())
73+
Expect(err.Error()).To(ContainSubstring("unsafe path"))
74+
Expect(filepath.Join(tmpDir, "escaped.txt")).ToNot(BeAnExistingFile())
75+
})
6276
})
6377

6478
func writeZipArchive(path string, files map[string]string) (err error) {
@@ -126,3 +140,29 @@ func writeTarArchive(path string, files map[string]string) (err error) {
126140

127141
return nil
128142
}
143+
144+
func writeTarArchiveWithHardlink(path, name, linkname string) (err error) {
145+
out, err := os.Create(path)
146+
if err != nil {
147+
return err
148+
}
149+
defer func() {
150+
if closeErr := out.Close(); err == nil {
151+
err = closeErr
152+
}
153+
}()
154+
155+
writer := tar.NewWriter(out)
156+
defer func() {
157+
if closeErr := writer.Close(); err == nil {
158+
err = closeErr
159+
}
160+
}()
161+
162+
return writer.WriteHeader(&tar.Header{
163+
Name: name,
164+
Linkname: linkname,
165+
Typeflag: tar.TypeLink,
166+
Mode: 0o600,
167+
})
168+
}

0 commit comments

Comments
 (0)