Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions internal/packageimport/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ type Importer struct {
Store *store.Store
}

const maxRemoteArchiveBytes int64 = 512 << 20

var defaultArchiveExtractionLimits = archiveExtractionLimits{
MaxFiles: 100_000,
MaxEntryBytes: 512 << 20,
MaxTotalBytes: 2 << 30,
}

type archiveExtractionLimits struct {
MaxFiles int
MaxEntryBytes uint64
MaxTotalBytes uint64
}

type Options struct {
ServiceID string `json:"service_id"`
Name string `json:"name"`
Expand Down Expand Up @@ -649,7 +663,7 @@ func (i *Importer) prepareSource(ctx context.Context, opts Options, staging stri
err = untarGz(artifactPath, packageDir)
}
if err != nil {
return preparedSource{}, err
return preparedSource{}, fmt.Errorf("extract remote package %q: %w", redactedRemoteArchiveSource(source), err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本地包解压失败被误标为 "extract remote package",来源上下文包装加错了分支

变更把 prepareSource 中 default 分支的解压失败错误包装为 fmt.Errorf("extract remote package %q: %w", redactedRemoteArchiveSource(source), err)。但该分支位于 classifySource switch 之后:远程归档(sourceRemoteArchive)、npm、Git 都在 switch 内提前 return,此处的 else 分支只处理本地文件系统文件(os.Stat 为文件的本地路径),永远不会是远程包。因此:1) 任何本地 .tgz/.zip(或落入 untarGz 的其他扩展名文件)解压失败时,错误信息会误导性地声称是 "remote package";2) redactedRemoteArchiveSource 用 url.Parse 处理本地路径,文件名含 #、? 等合法字符时会被当作 fragment/query 剥离,报错显示的路径被截断(例如本地 /tmp/a#b.tgz 会显示为 /tmp/a),反而丢失了要补充的定位信息;3) 真正需要来源上下文的远程解压失败路径 prepareRemoteArchiveSource(unzip/untarGz 失败,约 806-809 行)仍直接返回裸 err,本次改动没有给远程解压错误补充上下文,目标未达成且加错了位置。

Problem code:

Changed code at internal/packageimport/importer.go:666

Recommendation:
将 "extract remote package %q" 包装移到真正处理远程归档的 prepareRemoteArchiveSource 中 unzip/untarGz 失败分支(补充远程解压错误上下文,符合既有 "download remote package %q" 风格);本地文件解压分支改用不含 URL 语义的文案并直接使用原始本地路径 source,避免 url.Parse 截断,例如:if err != nil { return preparedSource{}, fmt.Errorf("extract package %q: %w", source, err) }。

Suggested diff:

diff --git a/internal/packageimport/importer.go b/internal/packageimport/importer.go
--- a/internal/packageimport/importer.go
+++ b/internal/packageimport/importer.go
@@ -663,7 +663,7 @@ func (i *Importer) prepareSource(ctx context.Context, opts Options, staging stri
 		}
 		if err != nil {
-			return preparedSource{}, fmt.Errorf("extract remote package %q: %w", redactedRemoteArchiveSource(source), err)
+			return preparedSource{}, fmt.Errorf("extract package %q: %w", source, err)
 		}
 		packageDir = normalizePackageDir(packageDir)
 	}
@@ -806,7 +806,7 @@ func prepareRemoteArchiveSource(ctx context.Context, source, serviceRoot, staging
 			err = untarGz(artifactPath, packageDir)
 		}
 		if err != nil {
-			return preparedSource{}, err
+			return preparedSource{}, fmt.Errorf("extract remote package %q: %w", redactedRemoteArchiveSource(source), err)
 		}
 		packageDir = normalizePackageDir(packageDir)

}
packageDir = normalizePackageDir(packageDir)
}
Expand Down Expand Up @@ -832,21 +846,39 @@ func downloadRemoteArchive(ctx context.Context, source, artifactPath string) err
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download remote package %q: HTTP %d", redactedRemoteArchiveSource(source), resp.StatusCode)
}
if resp.ContentLength > maxRemoteArchiveBytes {
return fmt.Errorf("download remote package %q: archive exceeds %d byte limit", redactedRemoteArchiveSource(source), maxRemoteArchiveBytes)
}
if err := os.MkdirAll(filepath.Dir(artifactPath), 0o755); err != nil {
return err
}
out, err := os.OpenFile(artifactPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
_, copyErr := io.Copy(out, resp.Body)
copyErr := copyWithByteLimit(out, resp.Body, maxRemoteArchiveBytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

资源限制错误信息缺少包来源上下文,故障定位困难

本次变更新增的运行时资源限制错误路径与同函数内其他错误路径不一致:downloadRemoteArchive 中 ContentLength 预检失败返回 "download remote package %q: archive exceeds ..." 带 URL 上下文,但 copyWithByteLimit 运行时超限返回的 "content exceeds ... byte limit" 被直接透传,未包装 source;同样,prepareRemoteArchiveSource 中 unzip/untarGz 返回的 "archive exceeds ... file limit" / "archive exceeds ... expanded byte limit" / "archive entry ... exceeds ... byte limit" 也未包装任何归档来源。当导入失败触发资源限制时,用户/运维无法从错误信息判断是哪个远程包或上传包导致的,增加了安全事件与故障排查成本。

Problem code:

Changed code at internal/packageimport/importer.go:859

Recommendation:
在 downloadRemoteArchive 中用 fmt.Errorf("download remote package %q: %w", redactedRemoteArchiveSource(source), err) 包装 copyWithByteLimit 的错误;在 prepareRemoteArchiveSource 中对 unzip/untarGz 的错误统一包装归档来源(如 redactedRemoteArchiveSource(source)),使所有限制错误都带可定位的来源上下文。

Suggested diff:

diff --git a/internal/packageimport/importer.go b/internal/packageimport/importer.go
--- a/internal/packageimport/importer.go
+++ b/internal/packageimport/importer.go
@@ downloadRemoteArchive
 	copyErr := copyWithByteLimit(out, resp.Body, maxRemoteArchiveBytes)
 	closeErr := out.Close()
 	if copyErr != nil {
 		_ = os.Remove(artifactPath)
-		return copyErr
+		return fmt.Errorf("download remote package %q: %w", redactedRemoteArchiveSource(source), copyErr)
 	}
@@ prepareRemoteArchiveSource
 	if err != nil {
-		return preparedSource{}, err
+		return preparedSource{}, fmt.Errorf("extract %q: %w", redactedRemoteArchiveSource(source), err)
 	}

closeErr := out.Close()
if copyErr != nil {
return copyErr
_ = os.Remove(artifactPath)
return fmt.Errorf("download remote package %q: %w", redactedRemoteArchiveSource(source), copyErr)
}
if closeErr != nil {
_ = os.Remove(artifactPath)
}
return closeErr
}

func copyWithByteLimit(dst io.Writer, src io.Reader, max int64) error {
written, err := io.Copy(dst, io.LimitReader(src, max+1))
if err != nil {
return err
}
if written > max {
return fmt.Errorf("content exceeds %d byte limit", max)
}
return nil
}

func redactedRemoteArchiveSource(source string) string {
u, err := url.Parse(source)
if err != nil {
Expand Down Expand Up @@ -1630,6 +1662,10 @@ func tarGzDir(src, dst string) error {
}

func untarGz(src, dst string) error {
return untarGzWithLimits(src, dst, defaultArchiveExtractionLimits)
}

func untarGzWithLimits(src, dst string, limits archiveExtractionLimits) error {
file, err := os.Open(src)
if err != nil {
return err
Expand All @@ -1641,6 +1677,8 @@ func untarGz(src, dst string) error {
}
defer gz.Close()
tr := tar.NewReader(gz)
files := 0
var total uint64
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
Expand All @@ -1649,6 +1687,17 @@ func untarGz(src, dst string) error {
if err != nil {
return err
}
files++
if files > limits.MaxFiles {
return fmt.Errorf("archive exceeds %d file limit", limits.MaxFiles)
}
if hdr.Size < 0 || uint64(hdr.Size) > limits.MaxEntryBytes {
return fmt.Errorf("archive entry %q exceeds %d byte limit", hdr.Name, limits.MaxEntryBytes)
}
if uint64(hdr.Size) > limits.MaxTotalBytes-total {
return fmt.Errorf("archive exceeds %d expanded byte limit", limits.MaxTotalBytes)
}
total += uint64(hdr.Size)
clean := filepath.Clean(hdr.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("unsafe archive path %q", hdr.Name)
Expand All @@ -1667,7 +1716,7 @@ func untarGz(src, dst string) error {
if err != nil {
return err
}
_, copyErr := io.Copy(out, tr)
copyErr := copyWithByteLimit(out, tr, hdr.Size)
closeErr := out.Close()
if copyErr != nil {
return copyErr
Expand All @@ -1680,12 +1729,27 @@ func untarGz(src, dst string) error {
}

func unzip(src, dst string) error {
return unzipWithLimits(src, dst, defaultArchiveExtractionLimits)
}

func unzipWithLimits(src, dst string, limits archiveExtractionLimits) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
if len(r.File) > limits.MaxFiles {
return fmt.Errorf("archive exceeds %d file limit", limits.MaxFiles)
}
var total uint64
for _, file := range r.File {
if file.UncompressedSize64 > limits.MaxEntryBytes {
return fmt.Errorf("archive entry %q exceeds %d byte limit", file.Name, limits.MaxEntryBytes)
}
if file.UncompressedSize64 > limits.MaxTotalBytes-total {
return fmt.Errorf("archive exceeds %d expanded byte limit", limits.MaxTotalBytes)
}
total += file.UncompressedSize64
clean := filepath.Clean(file.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("unsafe archive path %q", file.Name)
Expand All @@ -1709,7 +1773,7 @@ func unzip(src, dst string) error {
_ = in.Close()
return err
}
_, copyErr := io.Copy(out, in)
copyErr := copyWithByteLimit(out, in, int64(file.UncompressedSize64))
closeIn := in.Close()
closeOut := out.Close()
if copyErr != nil {
Expand Down
147 changes: 147 additions & 0 deletions internal/packageimport/resource_limits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package packageimport

import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
)

func TestCopyWithByteLimitAllowsExactSize(t *testing.T) {
var dst bytes.Buffer
if err := copyWithByteLimit(&dst, strings.NewReader("exact"), int64(len("exact"))); err != nil {
t.Fatal(err)
}
if dst.String() != "exact" {
t.Fatalf("copied content = %q", dst.String())
}
}

func TestCopyWithByteLimitRejectsOversizedContent(t *testing.T) {
var dst bytes.Buffer
err := copyWithByteLimit(&dst, strings.NewReader("oversized"), 4)
if err == nil || !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("copy limit error = %v", err)
}
}

func TestTarExtractionEnforcesExpandedSizeLimit(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "package.tgz")
file, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(file)
tarWriter := tar.NewWriter(gz)
body := []byte("0123456789")
if err := tarWriter.WriteHeader(&tar.Header{Name: "package/data", Mode: 0o600, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write(body); err != nil {
t.Fatal(err)
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}

err = untarGzWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{
MaxFiles: 10,
MaxEntryBytes: 5,
MaxTotalBytes: 5,
})
if err == nil || !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("tar limit error = %v", err)
}
}

func TestTarExtractionEnforcesFileCountLimit(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "package.tgz")
writeTarArchive(t, archivePath, tarEntry{name: "package/a", body: "a"}, tarEntry{name: "package/b", body: "b"})
err := untarGzWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{MaxFiles: 1, MaxEntryBytes: 10, MaxTotalBytes: 10})
if err == nil || !strings.Contains(err.Error(), "file limit") {
t.Fatalf("tar file limit error = %v", err)
}
}

func TestTarExtractionEnforcesTotalExpandedSizeLimit(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "package.tgz")
writeTarArchive(t, archivePath, tarEntry{name: "package/a", body: "abc"}, tarEntry{name: "package/b", body: "def"})
err := untarGzWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{MaxFiles: 10, MaxEntryBytes: 10, MaxTotalBytes: 5})
if err == nil || !strings.Contains(err.Error(), "expanded byte limit") {
t.Fatalf("tar total size error = %v", err)
}
}

func TestZipExtractionEnforcesEntryAndTotalSizeLimits(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "package.zip")
writeZipArchive(t, archivePath, "package/a", "abc")
// The entry-size check is covered independently; this archive is enough to
// exercise the total-size check after the first entry.
archivePath = filepath.Join(t.TempDir(), "package.zip")
writeZipArchive(t, archivePath, "package/a", "abcdef")
if err := unzipWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{MaxFiles: 10, MaxEntryBytes: 2, MaxTotalBytes: 10}); err == nil || !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("zip entry size error = %v", err)
}
if err := unzipWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{MaxFiles: 10, MaxEntryBytes: 10, MaxTotalBytes: 5}); err == nil || !strings.Contains(err.Error(), "expanded byte limit") {
t.Fatalf("zip total size error = %v", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestZipExtractionEnforcesEntryAndTotalSizeLimits 含死代码且未真正验证跨条目累计总量限制

新增的 TestZipExtractionEnforcesEntryAndTotalSizeLimits 存在两个问题:1) 函数开头 writeZipArchive(t, archivePath, "package/a", "abc") 写入的归档在其后 archivePath 被重新指向新的 t.TempDir() 后即被丢弃,属于无效死代码(t.TempDir() 每次返回全新目录,第一次写入的归档从未被读取);2) 最终使用的归档仅含单一条目 "abcdef"(6 字节),当 MaxTotalBytes=5 时在第一条目即触发超限,只能证明『单条目超过总量』会被拒绝,无法验证总量限制的核心语义『多个各自未超限(MaxEntryBytes=10 下 3 字节条目)的条目累计超过总量时被拒绝』。而函数内注释却声称 'this archive is enough to exercise the total-size check after the first entry',暗示存在第二条目,注释与实际代码不符。对比同批新增的 TestTarExtractionEnforcesTotalExpandedSizeLimit(正确使用两个 3 字节条目),本测试未真正覆盖 zip 的跨条目累计总量检查;若未来实现对 zip 仅做单条目总量判断而不再跨条目累计(回归到仅防单条目超大、漏防跨条目 zip-bomb),此测试仍会通过,形成安全相关回归盲区。

Problem code:

Changed code at internal/packageimport/resource_limits_test.go:86-97

Recommendation:
删除开头两次无效的 writeZipArchive/archivePath 赋值死代码;将归档改为包含两个各自低于 MaxEntryBytes 但合计超过 MaxTotalBytes 的条目(例如 'package/a' 内容 'abc'、'package/b' 内容 'def',MaxTotalBytes 设为 5),以真正驱动跨条目累计后的总量检查,并同步修正注释。若现有 writeZipArchive 辅助函数仅支持写入单一条目,可仿照 writeTarArchive 的变参形式扩展它。

Suggested diff:

 	archivePath := filepath.Join(t.TempDir(), "package.zip")
-	writeZipArchive(t, archivePath, "package/a", "abc")
-	// The entry-size check is covered independently; this archive is enough to
-	// exercise the total-size check after the first entry.
-	archivePath = filepath.Join(t.TempDir(), "package.zip")
-	writeZipArchive(t, archivePath, "package/a", "abcdef")
+	// 条目大小限制由独立场景覆盖;此处写入两个各 3 字节的条目,
+	// 每个都低于 MaxEntryBytes,但合计 6 字节超过 MaxTotalBytes=5,
+	// 用于验证跨条目累计后的总量限制。
+	writeZipArchive(t, archivePath, "package/a", "abc")
+	writeZipArchive(t, archivePath, "package/b", "def") // 需使辅助函数支持同一归档追加/多条目写入
 	if err := unzipWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{MaxFiles: 10, MaxEntryBytes: 2, MaxTotalBytes: 10}); err == nil || !strings.Contains(err.Error(), "byte limit") {
 		t.Fatalf("zip entry size error = %v", err)
 	}
 	if err := unzipWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{MaxFiles: 10, MaxEntryBytes: 10, MaxTotalBytes: 5}); err == nil || !strings.Contains(err.Error(), "expanded byte limit") {
 		t.Fatalf("zip total size error = %v", err)
 	}

}

func TestZipExtractionAllowsArchiveWithinLimits(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "package.zip")
writeZipArchive(t, archivePath, "package/data", "ok")
dst := t.TempDir()
if err := unzipWithLimits(archivePath, dst, archiveExtractionLimits{MaxFiles: 10, MaxEntryBytes: 10, MaxTotalBytes: 10}); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join(dst, "package", "data"))
if err != nil {
t.Fatal(err)
}
if string(content) != "ok" {
t.Fatalf("extracted content = %q", content)
}
}

func TestZipExtractionEnforcesFileCountLimit(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "package.zip")
file, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
zipWriter := zip.NewWriter(file)
for _, name := range []string{"package/a", "package/b"} {
entry, err := zipWriter.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := entry.Write(bytes.Repeat([]byte{'x'}, 1)); err != nil {
t.Fatal(err)
}
}
if err := zipWriter.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}

err = unzipWithLimits(archivePath, t.TempDir(), archiveExtractionLimits{
MaxFiles: 1,
MaxEntryBytes: 10,
MaxTotalBytes: 10,
})
if err == nil || !strings.Contains(err.Error(), "file limit") {
t.Fatalf("zip limit error = %v", err)
}
}
Comment thread
monkeyscan[bot] marked this conversation as resolved.
Loading