Root cause
Injection & unsafe execution introduced
Add file transfer support using archive/tar
How AI contributed: Direct introductionPublished Jun 19, 2026 in
qbee-io/transport
Archive extraction followed symlink components outside the selected destination under the recipient process authority. Earlier repairs left distinct intermediate-path and symlink-creation surfaces.
Claude-coauthored extraction code opened regular tar entries with truncation after only lexical path checks. An earlier symlink entry could redirect that open outside the extraction directory; the fix rejects existing link targets and creates new files exclusively.
Root cause
Add file transfer support using archive/tar
Fix
Remove symlink support from file transfer
Case facts
+// Symlinks with targets outside destPath are rejected.+func extractTar(tr *tar.Reader, destPath string) error {+ destPath = filepath.Clean(destPath)++ for {+ header, err := tr.Next()+ if err == io.EOF {+ return nil+ }++ if err != nil {+ return fmt.Errorf("error reading tar: %w", err)+ }++ targetPath, err := validatePath(destPath, header.Name)+ if err != nil {+ return err+ }++ switch header.Typeflag {+ case tar.TypeDir:+ if err = os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {+ return fmt.Errorf("error creating directory %s: %w", targetPath, err)+ }++ case tar.TypeReg:+ if err = extractFile(tr, targetPath, header); err != nil {+ return err+ }++ case tar.TypeSymlink:+ if err = extractSymlink(destPath, targetPath, header); err != nil {+ return err+ }++ default:+ // Skip unsupported entry types.+ continue+ }+ }+}++func extractFile(tr *tar.Reader, targetPath string, header *tar.Header) error {+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {+ return fmt.Errorf("error creating parent directory for %s: %w", targetPath, err)+ }++ f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))+ if err != nil {+ return fmt.Errorf("error creating file %s: %w", targetPath, err)+ }+ defer f.Close()++ if _, err = io.Copy(f, io.LimitReader(tr, header.Size)); err != nil {+ return fmt.Errorf("error extracting file %s: %w", targetPath, err)+ }++ return nil+}++func extractSymlink(destPath, targetPath string, header *tar.Header) error {+ if err := validateSymlink(destPath, header.Linkname, targetPath); err != nil {+ return err+ }++ // Remove existing file/symlink at target path before creating new symlink.+ _ = os.Remove(targetPath)++ if err := os.Symlink(header.Linkname, targetPath); err != nil {+ return fmt.Errorf("error creating symlink %s: %w", targetPath, err)+ }++ return nil+}++// validatePath checks that the given tar entry name resolves to a path+// within the destination directory. Returns the cleaned absolute path.+func validatePath(basePath, entryName string) (string, error) {+ cleanBase := filepath.Clean(basePath)+ fullPath := filepath.Join(cleanBase, filepath.FromSlash(entryName))+ cleanPath := filepath.Clean(fullPath)++ // The path must be the base itself or a child of it.+ if cleanPath == cleanBase {+ return cleanPath, nil+ }++ if !strings.HasPrefix(cleanPath, cleanBase+string(filepath.Separator)) {+ return "", fmt.Errorf("path traversal detected: %s", entryName)+ }@@ -275,7 +275,7 @@ func handleUpload(stream *smux.Stream, destPath string) error { // For a single file, the archive contains one entry with the file's base name. // For a directory, the archive contains the directory and all its contents, // preserving the top-level directory name.-// Symlinks pointing outside basePath are silently skipped.+// Symlinks are silently skipped. func archivePath(tarWriter *tar.Writer, basePath string) error { basePath = filepath.Clean(basePath) @@ -294,8 +294,6 @@ func archivePath(tarWriter *tar.Writer, basePath string) error { relPath = filepath.ToSlash(relPath) switch {- case info.Mode()&os.ModeSymlink != 0:- return archiveSymlink(tarWriter, basePath, path, relPath) case info.IsDir(): return archiveDir(tarWriter, relPath, info) case info.Mode().IsRegular():@@ -345,39 +343,8 @@ func archiveFile(tarWriter *tar.Writer, absPath, relPath string, info os.FileInf return nil } -func archiveSymlink(tarWriter *tar.Writer, basePath, absPath, relPath string) error {- linkTarget, err := os.Readlink(absPath)- if err != nil {- return fmt.Errorf("error reading symlink %s: %w", absPath, err)- }-- // Validate the symlink target stays within the base path.- if err = validateSymlink(basePath, linkTarget, absPath); err != nil {- // Skip symlinks that escape the base directory.- return nil- }-- // Convert absolute targets to relative paths for archive portability.- if filepath.IsAbs(linkTarget) {- linkTarget, err = filepath.Rel(filepath.Dir(absPath), linkTarget)- if err != nil {- // Skip if we can't compute a relative path.- return nil- }- }-- header := &tar.Header{- Typeflag: tar.TypeSymlink,- Name: relPath,- Linkname: filepath.ToSlash(linkTarget),- }-- return tarWriter.WriteHeader(header)-}- // extractTar reads a tar archive and extracts its contents to destPath. // All paths are validated to prevent directory traversal attacks.-// Symlinks with targets outside destPath are rejected. // // For directories: creates destPath if needed and extracts contents. // For single files: destPath can be an existing directory (extract file there),@@ -472,11 +439,6 @@ func extractTarEntries(tarReader *tar.Reader, destPath, stripPrefix string) erro return err } - case tar.TypeSymlink:- if err = extractSymlink(destPath, root, relPath, header); err != nil {- return err- }- default: // Skip unsupported entry types. continue@@ -523,25 +485,6 @@ func extractSingleFile(tarReader *tar.Reader, destPath string, header *tar.Heade return extractFile(root, filepath.Base(destPath), tarReader, header) } -func extractSymlink(destPath string, root *os.Root, relPath string, header *tar.Header) error {- targetPath := filepath.Join(destPath, relPath)- if err := validateSymlink(destPath, header.Linkname, targetPath); err != nil {- return err- }-- // Remove existing file/symlink at target path before creating new symlink.- _ = root.Remove(relPath)-- // The parent components were created through the root, so they are genuine- // directories (not attacker-planted symlinks); creating the link does not follow- // any path component and the validated target stays within destPath.- if err := os.Symlink(header.Linkname, targetPath); err != nil {- return fmt.Errorf("error creating symlink %s: %w", relPath, err)- }-AI-assisted change c70a122b090696ce93273148554d68eec1dcf4e4b43e713532490022b981d4ad · Fix c5f94fdb5b9eb16c439726ff6d72bbbf52b8221f7ad0d965117596c230cb9870