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>
137 lines
3.1 KiB
Go
137 lines
3.1 KiB
Go
package utils
|
|
|
|
import (
|
|
"archive/tar"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/klauspost/compress/zip"
|
|
"github.com/mholt/archiver/v3"
|
|
)
|
|
|
|
func IsArchive(file string) bool {
|
|
uaIface, err := archiver.ByExtension(file)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
_, ok := uaIface.(archiver.Unarchiver)
|
|
return ok
|
|
}
|
|
|
|
func ExtractArchive(archive, dst string) error {
|
|
uaIface, err := archiver.ByExtension(archive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
un, ok := uaIface.(archiver.Unarchiver)
|
|
if !ok {
|
|
return fmt.Errorf("format specified by source filename is not an archive format: %s (%T)", archive, uaIface)
|
|
}
|
|
|
|
mytar := &archiver.Tar{
|
|
OverwriteExisting: true,
|
|
MkdirAll: true,
|
|
ImplicitTopLevelFolder: false,
|
|
ContinueOnError: false,
|
|
}
|
|
|
|
switch v := uaIface.(type) {
|
|
case *archiver.Tar:
|
|
uaIface = mytar
|
|
case *archiver.TarBrotli:
|
|
v.Tar = mytar
|
|
case *archiver.TarBz2:
|
|
v.Tar = mytar
|
|
case *archiver.TarGz:
|
|
v.Tar = mytar
|
|
case *archiver.TarLz4:
|
|
v.Tar = mytar
|
|
case *archiver.TarSz:
|
|
v.Tar = mytar
|
|
case *archiver.TarXz:
|
|
v.Tar = mytar
|
|
case *archiver.TarZstd:
|
|
v.Tar = mytar
|
|
}
|
|
|
|
extractRoot, err := filepath.Abs(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = archiver.Walk(archive, func(f archiver.File) error {
|
|
if err := validateArchiveMemberPath(extractRoot, archiveMemberName(f)); err != nil {
|
|
return err
|
|
}
|
|
if f.FileInfo.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("archive contains a symlink")
|
|
}
|
|
if linkname, ok := archiveMemberLinkname(f); ok {
|
|
if err := validateArchiveMemberPath(extractRoot, linkname); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return un.Unarchive(archive, dst)
|
|
}
|
|
|
|
func archiveMemberName(f archiver.File) string {
|
|
switch h := f.Header.(type) {
|
|
case tar.Header:
|
|
return h.Name
|
|
case *tar.Header:
|
|
return h.Name
|
|
case zip.FileHeader:
|
|
return h.Name
|
|
case *zip.FileHeader:
|
|
return h.Name
|
|
default:
|
|
return f.Name()
|
|
}
|
|
}
|
|
|
|
// archiveMemberLinkname reports the target of a tar hardlink member, which carries a regular file mode and so is not caught by the symlink check.
|
|
func archiveMemberLinkname(f archiver.File) (string, bool) {
|
|
switch h := f.Header.(type) {
|
|
case tar.Header:
|
|
return h.Linkname, h.Typeflag == tar.TypeLink
|
|
case *tar.Header:
|
|
return h.Linkname, h.Typeflag == tar.TypeLink
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func validateArchiveMemberPath(root, name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("archive contains an empty path")
|
|
}
|
|
|
|
normalizedName := filepath.FromSlash(strings.ReplaceAll(name, "\\", "/"))
|
|
cleanedName := filepath.Clean(normalizedName)
|
|
if filepath.IsAbs(cleanedName) || cleanedName == ".." || strings.HasPrefix(cleanedName, ".."+string(os.PathSeparator)) {
|
|
return fmt.Errorf("archive contains an unsafe path: %s", name)
|
|
}
|
|
|
|
targetPath := filepath.Join(root, cleanedName)
|
|
relativePath, err := filepath.Rel(root, targetPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(os.PathSeparator)) || filepath.IsAbs(relativePath) {
|
|
return fmt.Errorf("archive contains an unsafe path: %s", name)
|
|
}
|
|
|
|
return nil
|
|
}
|