Skip to content

Commit c58e7ca

Browse files
committed
oci: address artifact extraction review feedback
1 parent d83d08c commit c58e7ca

12 files changed

Lines changed: 410 additions & 669 deletions

oci/artifact/tar.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ func ExtractTarWithLimit(data []byte, maxFileSize int64) ([]FileEntry, error) {
112112
}
113113

114114
// Reject path traversal
115-
if err := validateTarPath(hdr.Name); err != nil {
115+
cleanedPath, err := validateTarPath(hdr.Name)
116+
if err != nil {
116117
return nil, err
117118
}
118119

@@ -148,7 +149,7 @@ func ExtractTarWithLimit(data []byte, maxFileSize int64) ([]FileEntry, error) {
148149
}
149150

150151
files = append(files, FileEntry{
151-
Path: hdr.Name,
152+
Path: cleanedPath,
152153
Content: content,
153154
Mode: hdr.Mode,
154155
})
@@ -157,16 +158,20 @@ func ExtractTarWithLimit(data []byte, maxFileSize int64) ([]FileEntry, error) {
157158
return files, nil
158159
}
159160

160-
// validateTarPath checks that a tar entry path is safe.
161-
func validateTarPath(p string) error {
161+
// validateTarPath checks that a tar entry path is safe and returns its cleaned path.
162+
func validateTarPath(p string) (string, error) {
163+
if strings.Contains(p, `\\`) {
164+
return "", fmt.Errorf("backslash path separators not allowed in archive: %s", p)
165+
}
166+
162167
// path.Clean resolves all ".." segments; any remaining leading ".."
163168
// means the path escapes the archive root.
164169
cleaned := path.Clean(p)
165-
if strings.HasPrefix(cleaned, "..") {
166-
return fmt.Errorf("path traversal detected in archive: %s", p)
170+
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
171+
return "", fmt.Errorf("path traversal detected in archive: %s", p)
167172
}
168173
if path.IsAbs(cleaned) {
169-
return fmt.Errorf("absolute path not allowed in archive: %s", p)
174+
return "", fmt.Errorf("absolute path not allowed in archive: %s", p)
170175
}
171-
return nil
176+
return cleaned, nil
172177
}

oci/artifact/tar_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func TestExtractTar_RejectsPathTraversal(t *testing.T) {
205205
{name: "dotdot prefix", path: "../etc/passwd"},
206206
{name: "dotdot in middle", path: "foo/../../etc/passwd"},
207207
{name: "absolute path", path: "/etc/passwd"},
208+
{name: "windows traversal", path: `foo\\..\\..\\etc\\passwd`},
208209
}
209210

210211
for _, tt := range tests {
@@ -231,6 +232,30 @@ func TestExtractTar_RejectsPathTraversal(t *testing.T) {
231232
}
232233
}
233234

235+
func TestExtractTar_CleansPath(t *testing.T) {
236+
t.Parallel()
237+
238+
var buf bytes.Buffer
239+
tw := tar.NewWriter(&buf)
240+
241+
content := []byte("test")
242+
require.NoError(t, tw.WriteHeader(&tar.Header{
243+
Name: "foo/./bar.txt",
244+
Size: int64(len(content)),
245+
Typeflag: tar.TypeReg,
246+
Mode: 0644,
247+
}))
248+
_, err := tw.Write(content)
249+
require.NoError(t, err)
250+
require.NoError(t, tw.Close())
251+
252+
files, err := ExtractTar(buf.Bytes())
253+
require.NoError(t, err)
254+
require.Len(t, files, 1)
255+
assert.Equal(t, "foo/bar.txt", files[0].Path)
256+
assert.Equal(t, content, files[0].Content)
257+
}
258+
234259
func TestExtractTarWithLimit_RejectsOversized(t *testing.T) {
235260
t.Parallel()
236261

oci/artifact/validate.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func (v *ValidatingTarget) Tag(ctx context.Context, desc ocispec.Descriptor, ref
6666
// Push validates size and structure limits before delegating to the inner target.
6767
func (v *ValidatingTarget) Push(ctx context.Context, desc ocispec.Descriptor, content io.Reader) error {
6868
maxSize := MaxBlobSize
69-
if IsManifestMediaType(desc.MediaType) {
69+
if isManifestMediaType(desc.MediaType) {
7070
maxSize = MaxManifestSize
7171
}
7272

@@ -101,20 +101,20 @@ func (v *ValidatingTarget) Push(ctx context.Context, desc ocispec.Descriptor, co
101101
}
102102

103103
// Validate manifest/index structure limits
104-
if err := ValidateManifestCounts(desc.MediaType, data); err != nil {
104+
if err := validateManifestCounts(desc.MediaType, data); err != nil {
105105
return err
106106
}
107107

108108
return v.inner.Push(ctx, desc, bytes.NewReader(data))
109109
}
110110

111-
// ValidateManifestCounts checks layer/manifest counts for resource exhaustion prevention.
111+
// validateManifestCounts checks layer/manifest counts for resource exhaustion prevention.
112112
//
113113
// It only inspects media types it recognizes (image index and image manifest).
114114
// For any other media type it returns nil without examining the data. A nil
115115
// return therefore means "no count violation was detected", not "this manifest
116116
// is safe" — callers must not treat a nil return as a general safety guarantee.
117-
func ValidateManifestCounts(mediaType string, data []byte) error {
117+
func validateManifestCounts(mediaType string, data []byte) error {
118118
switch mediaType {
119119
case ocispec.MediaTypeImageIndex:
120120
var index ocispec.Index
@@ -142,8 +142,8 @@ func ValidateManifestCounts(mediaType string, data []byte) error {
142142
return nil
143143
}
144144

145-
// IsManifestMediaType returns true if the media type is a manifest or index type.
146-
func IsManifestMediaType(mediaType string) bool {
145+
// isManifestMediaType returns true if the media type is a manifest or index type.
146+
func isManifestMediaType(mediaType string) bool {
147147
switch mediaType {
148148
case ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex,
149149
"application/vnd.docker.distribution.manifest.v2+json",

oci/artifact/validate_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestIsManifestMediaType(t *testing.T) {
3535
for _, tt := range tests {
3636
t.Run(tt.name, func(t *testing.T) {
3737
t.Parallel()
38-
assert.Equal(t, tt.want, IsManifestMediaType(tt.mediaType))
38+
assert.Equal(t, tt.want, isManifestMediaType(tt.mediaType))
3939
})
4040
}
4141
}
@@ -198,7 +198,7 @@ func TestValidateManifestCounts(t *testing.T) {
198198
for _, tt := range tests {
199199
t.Run(tt.name, func(t *testing.T) {
200200
t.Parallel()
201-
err := ValidateManifestCounts(tt.mediaType, tt.data)
201+
err := validateManifestCounts(tt.mediaType, tt.data)
202202
if tt.wantErr {
203203
require.Error(t, err)
204204
assert.Contains(t, err.Error(), tt.errSubstr)

oci/skills/artifact_aliases.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,69 +18,92 @@ import (
1818
// Type aliases for tar/gzip primitives.
1919
type (
2020
// FileEntry represents a file to include in a tar archive.
21+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.FileEntry.
2122
FileEntry = artifact.FileEntry
2223
// TarOptions configures reproducible tar archive creation.
24+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.TarOptions.
2325
TarOptions = artifact.TarOptions
2426
// GzipOptions configures reproducible gzip compression.
27+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.GzipOptions.
2528
GzipOptions = artifact.GzipOptions
2629
)
2730

2831
// Function forwarding for tar primitives.
2932
var (
3033
// DefaultTarOptions returns default options for reproducible tar archives.
34+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.DefaultTarOptions.
3135
DefaultTarOptions = artifact.DefaultTarOptions
3236
// CreateTar creates a reproducible tar archive from the given files.
37+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.CreateTar.
3338
CreateTar = artifact.CreateTar
3439
// ExtractTar extracts files from a tar archive.
40+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.ExtractTar.
3541
ExtractTar = artifact.ExtractTar
3642
// ExtractTarWithLimit extracts files from a tar archive with a per-file size limit.
43+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.ExtractTarWithLimit.
3744
ExtractTarWithLimit = artifact.ExtractTarWithLimit
3845
)
3946

4047
// Function forwarding for gzip primitives.
4148
var (
4249
// DefaultGzipOptions returns default options for reproducible gzip compression.
50+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.DefaultGzipOptions.
4351
DefaultGzipOptions = artifact.DefaultGzipOptions
4452
// Compress creates a reproducible gzip compressed byte slice.
53+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.Compress.
4554
Compress = artifact.Compress
4655
// Decompress decompresses gzip data.
56+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.Decompress.
4757
Decompress = artifact.Decompress
4858
// DecompressWithLimit decompresses gzip data with a size limit.
59+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.DecompressWithLimit.
4960
DecompressWithLimit = artifact.DecompressWithLimit
5061
// CompressTar creates a reproducible .tar.gz from the given files.
62+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.CompressTar.
5163
CompressTar = artifact.CompressTar
5264
// DecompressTar extracts files from a .tar.gz archive.
65+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.DecompressTar.
5366
DecompressTar = artifact.DecompressTar
5467
)
5568

5669
// Function forwarding for platform helpers.
5770
var (
5871
// PlatformString returns the platform in "os/arch" or "os/arch/variant" format.
72+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.PlatformString.
5973
PlatformString = artifact.PlatformString
6074
// ParsePlatform parses a platform string in "os/arch" or "os/arch/variant" format.
75+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.ParsePlatform.
6176
ParsePlatform = artifact.ParsePlatform
6277
// DefaultPlatforms are the default platforms for artifacts.
78+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.DefaultPlatforms.
6379
DefaultPlatforms = artifact.DefaultPlatforms
6480
)
6581

6682
// Size limit constants re-exported from the artifact package.
6783
const (
6884
// MaxTarFileSize is the maximum size of a single file in a tar archive (100MB).
85+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.MaxTarFileSize.
6986
MaxTarFileSize = artifact.MaxTarFileSize
7087
// MaxDecompressedSize is the maximum size of decompressed data (100MB).
88+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.MaxDecompressedSize.
7189
MaxDecompressedSize = artifact.MaxDecompressedSize
7290
// MaxManifestSize is the maximum size of a manifest (1MB).
91+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.MaxManifestSize.
7392
MaxManifestSize = artifact.MaxManifestSize
7493
// MaxBlobSize is the maximum size of a blob (100MB).
94+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.MaxBlobSize.
7595
MaxBlobSize = artifact.MaxBlobSize
7696
)
7797

7898
// OS and architecture constants for OCI platform specifications.
7999
const (
80100
// OSLinux is the Linux OS identifier used in OCI platform specs.
101+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.OSLinux.
81102
OSLinux = artifact.OSLinux
82103
// ArchAMD64 is the x86-64 architecture identifier used in OCI platform specs.
104+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.ArchAMD64.
83105
ArchAMD64 = artifact.ArchAMD64
84106
// ArchARM64 is the 64-bit ARM architecture identifier used in OCI platform specs.
107+
// Deprecated: use github.com/stacklok/toolhive-core/oci/artifact.ArchARM64.
85108
ArchARM64 = artifact.ArchARM64
86109
)

oci/skills/artifact_aliases_test.go

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)