mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
* 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> * test(utils): cover hardlink overwrite and in-root hardlinks The existing hardlink test names a link target two levels above the extraction root, so its final assertion checked a path the link never resolved to and could not fail. Point the target one level up instead, at the path that assertion already names. Add two cases. The first uses a .tar.gz, where ExtractArchive binds a Tar config with OverwriteExisting set, and follows the link entry with a regular entry of the same name. Before the fix that pair linked to a file outside the root and then truncated it through the link, which the plain .tar case does not reach. The second extracts a hardlink whose target is an earlier member of the same archive, covering the claim that ordinary archives are unaffected. Assisted-by: Claude:claude-opus-5 Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com> --------- Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>
295 lines
6.9 KiB
Go
295 lines
6.9 KiB
Go
package utils_test
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
. "github.com/mudler/LocalAI/pkg/utils"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("utils/archive tests", func() {
|
|
It("extracts regular nested zip members", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
archivePath := filepath.Join(tmpDir, "model.zip")
|
|
extractPath := filepath.Join(tmpDir, "models")
|
|
|
|
Expect(writeZipArchive(archivePath, map[string]string{
|
|
"nested/model.yaml": "name: test",
|
|
})).To(Succeed())
|
|
|
|
Expect(ExtractArchive(archivePath, extractPath)).To(Succeed())
|
|
|
|
extracted, err := os.ReadFile(filepath.Join(extractPath, "nested", "model.yaml"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(extracted)).To(Equal("name: test"))
|
|
})
|
|
|
|
It("rejects zip members that escape the destination", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
archivePath := filepath.Join(tmpDir, "model.zip")
|
|
extractPath := filepath.Join(tmpDir, "models")
|
|
|
|
Expect(writeZipArchive(archivePath, map[string]string{
|
|
"../escaped.txt": "escaped",
|
|
})).To(Succeed())
|
|
|
|
err := ExtractArchive(archivePath, extractPath)
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("unsafe path"))
|
|
Expect(filepath.Join(tmpDir, "escaped.txt")).ToNot(BeAnExistingFile())
|
|
})
|
|
|
|
It("rejects tar members that escape the destination", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
archivePath := filepath.Join(tmpDir, "model.tar")
|
|
extractPath := filepath.Join(tmpDir, "models")
|
|
|
|
Expect(writeTarArchive(archivePath, map[string]string{
|
|
"../escaped.txt": "escaped",
|
|
})).To(Succeed())
|
|
|
|
err := ExtractArchive(archivePath, extractPath)
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("unsafe path"))
|
|
Expect(filepath.Join(tmpDir, "escaped.txt")).ToNot(BeAnExistingFile())
|
|
})
|
|
|
|
It("rejects tar hardlinks that overwrite a file outside the destination", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
archivePath := filepath.Join(tmpDir, "model.tar.gz")
|
|
extractPath := filepath.Join(tmpDir, "models")
|
|
outsidePath := filepath.Join(tmpDir, "outside.txt")
|
|
|
|
Expect(os.WriteFile(outsidePath, []byte("original"), 0o600)).To(Succeed())
|
|
Expect(writeTarGzArchiveWithHardlinkedFile(archivePath, "payload.bin", "../outside.txt", "overwritten")).To(Succeed())
|
|
|
|
err := ExtractArchive(archivePath, extractPath)
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("unsafe path"))
|
|
|
|
contents, readErr := os.ReadFile(outsidePath)
|
|
Expect(readErr).ToNot(HaveOccurred())
|
|
Expect(string(contents)).To(Equal("original"))
|
|
})
|
|
|
|
It("extracts tar hardlinks that stay inside the destination", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
archivePath := filepath.Join(tmpDir, "model.tar.gz")
|
|
extractPath := filepath.Join(tmpDir, "models")
|
|
|
|
Expect(writeTarGzArchiveWithInternalHardlink(archivePath, "model.bin", "alias.bin", "weights")).To(Succeed())
|
|
|
|
Expect(ExtractArchive(archivePath, extractPath)).To(Succeed())
|
|
|
|
extracted, err := os.ReadFile(filepath.Join(extractPath, "alias.bin"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(extracted)).To(Equal("weights"))
|
|
})
|
|
|
|
It("rejects tar hardlinks that point outside the destination", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
archivePath := filepath.Join(tmpDir, "model.tar")
|
|
extractPath := filepath.Join(tmpDir, "models")
|
|
|
|
Expect(writeTarArchiveWithHardlink(archivePath, "payload.bin", "../escaped.txt")).To(Succeed())
|
|
|
|
err := ExtractArchive(archivePath, extractPath)
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("unsafe path"))
|
|
Expect(filepath.Join(tmpDir, "escaped.txt")).ToNot(BeAnExistingFile())
|
|
})
|
|
})
|
|
|
|
func writeZipArchive(path string, files map[string]string) (err error) {
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if closeErr := out.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
writer := zip.NewWriter(out)
|
|
defer func() {
|
|
if closeErr := writer.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
for name, contents := range files {
|
|
fileWriter, err := writer.Create(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fileWriter.Write([]byte(contents)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func writeTarArchive(path string, files map[string]string) (err error) {
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if closeErr := out.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
writer := tar.NewWriter(out)
|
|
defer func() {
|
|
if closeErr := writer.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
for name, contents := range files {
|
|
data := []byte(contents)
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Mode: 0o600,
|
|
Size: int64(len(data)),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := writer.Write(data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func writeTarArchiveWithHardlink(path, name, linkname string) (err error) {
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if closeErr := out.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
writer := tar.NewWriter(out)
|
|
defer func() {
|
|
if closeErr := writer.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
return writer.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Linkname: linkname,
|
|
Typeflag: tar.TypeLink,
|
|
Mode: 0o600,
|
|
})
|
|
}
|
|
|
|
func writeTarGzArchiveWithHardlinkedFile(path, name, linkname, contents string) (err error) {
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if closeErr := out.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
compressor := gzip.NewWriter(out)
|
|
defer func() {
|
|
if closeErr := compressor.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
writer := tar.NewWriter(compressor)
|
|
defer func() {
|
|
if closeErr := writer.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Linkname: linkname,
|
|
Typeflag: tar.TypeLink,
|
|
Mode: 0o600,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
data := []byte(contents)
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Mode: 0o600,
|
|
Size: int64(len(data)),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_, err = writer.Write(data)
|
|
|
|
return err
|
|
}
|
|
|
|
func writeTarGzArchiveWithInternalHardlink(path, targetName, linkName, contents string) (err error) {
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if closeErr := out.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
compressor := gzip.NewWriter(out)
|
|
defer func() {
|
|
if closeErr := compressor.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
writer := tar.NewWriter(compressor)
|
|
defer func() {
|
|
if closeErr := writer.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
data := []byte(contents)
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: targetName,
|
|
Mode: 0o600,
|
|
Size: int64(len(data)),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := writer.Write(data); err != nil {
|
|
return err
|
|
}
|
|
|
|
return writer.WriteHeader(&tar.Header{
|
|
Name: linkName,
|
|
Linkname: targetName,
|
|
Typeflag: tar.TypeLink,
|
|
Mode: 0o600,
|
|
})
|
|
}
|