Skip to content

Commit 31882f5

Browse files
committed
fix: address security and reliability issues in cp operations
- Add sanitizeTarPath to prevent path traversal in tar extraction - Pass followLinks to SDK CpToInstance calls - Fix symlinks being written twice to tar output - Update hypeman-go dependency to v0.7.1-0.20251223013311-b97d5b836009
1 parent 3919ffd commit 31882f5

File tree

3 files changed

+74
-31
lines changed

3 files changed

+74
-31
lines changed

go.mod

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ require (
1212
github.com/gorilla/websocket v1.5.3
1313
github.com/itchyny/json2yaml v0.1.4
1414
github.com/muesli/reflow v0.3.0
15-
github.com/onkernel/hypeman-go v0.7.0
15+
github.com/onkernel/hypeman-go v0.7.1-0.20251223013311-b97d5b836009
1616
github.com/tidwall/gjson v1.18.0
1717
github.com/tidwall/pretty v1.2.1
1818
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
@@ -74,5 +74,3 @@ require (
7474
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
7575
google.golang.org/grpc v1.75.1 // indirect
7676
)
77-
78-
replace github.com/onkernel/hypeman-go => /home/raf/code/hypeman-go

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
105105
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
106106
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
107107
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
108+
github.com/onkernel/hypeman-go v0.7.1-0.20251223013311-b97d5b836009 h1:LPFxYVNAEv4reF4ZFzd45rLPLRWr60NPaPIb0Z622X0=
109+
github.com/onkernel/hypeman-go v0.7.1-0.20251223013311-b97d5b836009/go.mod h1:Wtm4ewVGGPZc2ySeeuQISQyJxujyQuyDjXyksVkIyy8=
108110
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
109111
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
110112
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=

pkg/cmd/cp.go

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func handleCp(ctx context.Context, cmd *cli.Command) error {
186186
if srcPath == "-" {
187187
return copyFromStdinToInstance(ctx, baseURL, apiKey, instanceID, dstPath, archive)
188188
}
189-
return copyToInstance(ctx, baseURL, apiKey, instanceID, srcPath, dstPath, quiet, archive)
189+
return copyToInstance(ctx, baseURL, apiKey, instanceID, srcPath, dstPath, quiet, archive, followLinks)
190190
}
191191
}
192192

@@ -246,6 +246,42 @@ func isWindowsPath(path string) bool {
246246
return false
247247
}
248248

249+
// sanitizeTarPath validates and sanitizes a tar entry path to prevent path traversal attacks.
250+
// Returns the sanitized target path or an error if the path is malicious.
251+
func sanitizeTarPath(basePath, entryName string) (string, error) {
252+
// Clean the entry name
253+
clean := filepath.Clean(entryName)
254+
255+
// Reject absolute paths
256+
if filepath.IsAbs(clean) {
257+
return "", fmt.Errorf("invalid tar entry: absolute path not allowed: %s", entryName)
258+
}
259+
260+
// Reject paths that start with ..
261+
if strings.HasPrefix(clean, "..") {
262+
return "", fmt.Errorf("invalid tar entry: path escapes destination: %s", entryName)
263+
}
264+
265+
// Join with base path
266+
targetPath := filepath.Join(basePath, clean)
267+
268+
// Verify the result is under the base path
269+
absBase, err := filepath.Abs(basePath)
270+
if err != nil {
271+
return "", fmt.Errorf("resolve base path: %w", err)
272+
}
273+
absTarget, err := filepath.Abs(targetPath)
274+
if err != nil {
275+
return "", fmt.Errorf("resolve target path: %w", err)
276+
}
277+
278+
if !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) && absTarget != absBase {
279+
return "", fmt.Errorf("invalid tar entry: path escapes destination: %s", entryName)
280+
}
281+
282+
return targetPath, nil
283+
}
284+
249285
// statGuestPath queries the guest for information about a path
250286
func statGuestPath(ctx context.Context, baseURL, apiKey, instanceID, guestPath string, followLinks bool) (*cpStatResponse, error) {
251287
wsURL, err := buildCpWsURL(baseURL, instanceID)
@@ -389,7 +425,7 @@ func buildCpWsURL(baseURL, instanceID string) (string, error) {
389425
}
390426

391427
// copyToInstance copies a local file/directory to the instance
392-
func copyToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, quiet, archive bool) error {
428+
func copyToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, quiet, archive, followLinks bool) error {
393429
// Check for /. suffix (copy contents only)
394430
copyContentsOnly := strings.HasSuffix(srcPath, string(filepath.Separator)+".") || strings.HasSuffix(srcPath, "/.")
395431
originalSrcPath := srcPath
@@ -413,15 +449,15 @@ func copyToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, d
413449
if srcInfo.IsDir() {
414450
if copyContentsOnly {
415451
// Copy contents of srcPath into resolvedDst
416-
return copyDirContentsToInstance(ctx, baseURL, apiKey, instanceID, srcPath, resolvedDst, quiet, archive)
452+
return copyDirContentsToInstance(ctx, baseURL, apiKey, instanceID, srcPath, resolvedDst, quiet, archive, followLinks)
417453
}
418-
return copyDirToInstance(ctx, baseURL, apiKey, instanceID, srcPath, resolvedDst, quiet, archive)
454+
return copyDirToInstance(ctx, baseURL, apiKey, instanceID, srcPath, resolvedDst, quiet, archive, followLinks)
419455
}
420-
return copyFileToInstance(ctx, baseURL, apiKey, instanceID, srcPath, resolvedDst, srcInfo.Mode().Perm(), quiet, archive)
456+
return copyFileToInstance(ctx, baseURL, apiKey, instanceID, srcPath, resolvedDst, srcInfo.Mode().Perm(), quiet, archive, followLinks)
421457
}
422458

423459
// copyFileToInstance copies a single file to the instance using the SDK
424-
func copyFileToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, mode fs.FileMode, quiet, archive bool) error {
460+
func copyFileToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, mode fs.FileMode, quiet, archive, followLinks bool) error {
425461
srcInfo, err := os.Stat(srcPath)
426462
if err != nil {
427463
return fmt.Errorf("stat source: %w", err)
@@ -442,12 +478,13 @@ func copyFileToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPat
442478
}
443479

444480
err = lib.CpToInstance(ctx, cfg, lib.CpToInstanceOptions{
445-
InstanceID: instanceID,
446-
SrcPath: srcPath,
447-
DstPath: dstPath,
448-
Mode: mode,
449-
Archive: archive,
450-
Callbacks: callbacks,
481+
InstanceID: instanceID,
482+
SrcPath: srcPath,
483+
DstPath: dstPath,
484+
Mode: mode,
485+
Archive: archive,
486+
FollowLinks: followLinks,
487+
Callbacks: callbacks,
451488
})
452489
if err != nil {
453490
return err
@@ -457,7 +494,7 @@ func copyFileToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPat
457494
}
458495

459496
// copyDirToInstance copies a directory recursively to the instance using the SDK
460-
func copyDirToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, quiet, archive bool) error {
497+
func copyDirToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, quiet, archive, followLinks bool) error {
461498
cfg := lib.CpConfig{
462499
BaseURL: baseURL,
463500
APIKey: apiKey,
@@ -474,11 +511,12 @@ func copyDirToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath
474511

475512
// First create the destination directory
476513
err := lib.CpToInstance(ctx, cfg, lib.CpToInstanceOptions{
477-
InstanceID: instanceID,
478-
SrcPath: srcPath,
479-
DstPath: dstPath,
480-
Archive: archive,
481-
Callbacks: callbacks,
514+
InstanceID: instanceID,
515+
SrcPath: srcPath,
516+
DstPath: dstPath,
517+
Archive: archive,
518+
FollowLinks: followLinks,
519+
Callbacks: callbacks,
482520
})
483521
if err != nil {
484522
return err
@@ -489,7 +527,7 @@ func copyDirToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath
489527

490528
// copyDirContentsToInstance copies only the contents of a directory (not the directory itself)
491529
// This implements the /. suffix behavior from docker cp
492-
func copyDirContentsToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, quiet, archive bool) error {
530+
func copyDirContentsToInstance(ctx context.Context, baseURL, apiKey, instanceID, srcPath, dstPath string, quiet, archive, followLinks bool) error {
493531
entries, err := os.ReadDir(srcPath)
494532
if err != nil {
495533
return fmt.Errorf("read directory: %w", err)
@@ -519,12 +557,13 @@ func copyDirContentsToInstance(ctx context.Context, baseURL, apiKey, instanceID,
519557
}
520558

521559
if err := lib.CpToInstance(ctx, cfg, lib.CpToInstanceOptions{
522-
InstanceID: instanceID,
523-
SrcPath: srcEntryPath,
524-
DstPath: dstEntryPath,
525-
Mode: info.Mode().Perm(),
526-
Archive: archive,
527-
Callbacks: callbacks,
560+
InstanceID: instanceID,
561+
SrcPath: srcEntryPath,
562+
DstPath: dstEntryPath,
563+
Mode: info.Mode().Perm(),
564+
Archive: archive,
565+
FollowLinks: followLinks,
566+
Callbacks: callbacks,
528567
}); err != nil {
529568
return err
530569
}
@@ -695,7 +734,11 @@ func copyFromStdinToInstance(ctx context.Context, baseURL, apiKey, instanceID, d
695734
return fmt.Errorf("read tar header: %w", err)
696735
}
697736

698-
targetPath := filepath.Join(dstPath, header.Name)
737+
// Sanitize tar entry path to prevent path traversal attacks
738+
targetPath, err := sanitizeTarPath(dstPath, header.Name)
739+
if err != nil {
740+
return err
741+
}
699742

700743
// Extract uid/gid from tar header if archive mode
701744
var uid, gid uint32
@@ -860,8 +903,8 @@ func copyFromInstanceToStdout(ctx context.Context, baseURL, apiKey, instanceID,
860903

861904
switch msgTypeStr {
862905
case "header":
863-
// Flush previous file if any
864-
if currentHeader != nil && !currentHeader.IsDir {
906+
// Flush previous file if any (but not symlinks - they have no data)
907+
if currentHeader != nil && !currentHeader.IsDir && !currentHeader.IsSymlink {
865908
if err := writeTarEntry(tw, currentHeader, fileData); err != nil {
866909
return err
867910
}

0 commit comments

Comments
 (0)