fix: harden desktop release OSS uploads

This commit is contained in:
2026-06-22 23:49:24 +08:00
parent dcbab28e69
commit f0de364063
7 changed files with 218 additions and 63 deletions
@@ -602,8 +602,7 @@ func (s *DesktopClientReleaseService) InitiateDirectUpload(
return nil, err
}
}
contentMD5, err := normalizeDesktopReleaseContentMD5(in.ContentMD5)
if err != nil {
if _, err := normalizeDesktopReleaseContentMD5(in.ContentMD5); err != nil {
return nil, err
}
@@ -635,7 +634,7 @@ func (s *DesktopClientReleaseService) InitiateDirectUpload(
ctx,
objectKey,
detectDesktopReleaseContentType(fileName, nil),
contentMD5,
"",
desktopClientReleaseDirectUploadTTL,
)
if err != nil {
@@ -724,6 +723,21 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload(
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
}
}
actualSHA, actualSize, err := hashDesktopClientReleaseStorageObject(ctx, s.storage, objectKey)
if err != nil {
if errors.Is(err, objectstorage.ErrObjectNotFound) {
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
}
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
if actualSize != in.FileSizeBytes {
return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传")
}
if actualSHA != sha256Hex {
return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传")
}
return &DesktopClientReleaseUploadResult{
OSSObjectKey: objectKey,
@@ -733,6 +747,34 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload(
}, nil
}
func hashDesktopClientReleaseStorageObject(
ctx context.Context,
storage objectstorage.Client,
objectKey string,
) (string, int64, error) {
if readerClient, ok := storage.(objectstorage.ObjectReaderClient); ok {
reader, err := readerClient.GetReader(ctx, objectKey)
if err != nil {
return "", 0, err
}
defer reader.Close()
hasher := sha256.New()
size, err := io.Copy(hasher, reader)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
content, err := storage.GetBytes(ctx, objectKey)
if err != nil {
return "", 0, err
}
actualSHA := sha256.Sum256(content)
return hex.EncodeToString(actualSHA[:]), int64(len(content)), nil
}
func MaxDesktopClientReleaseMultipartBytes() int64 {
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
}
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
@@ -343,12 +344,15 @@ func TestDesktopClientReleaseDirectUploadUsesPresignedPutURL(t *testing.T) {
require.Equal(t, "desktop-client/releases/blobs/"+wantSHA+".dmg", initResult.OSSObjectKey)
require.Equal(t, "https://oss.example.test/upload/"+initResult.OSSObjectKey, initResult.UploadURL)
require.Equal(t, "application/x-apple-diskimage", initResult.Headers["Content-Type"])
require.Equal(t, "pt74NOtqjIwBJtPUYvJ8AA==", initResult.Headers["Content-Md5"])
require.NotContains(t, initResult.Headers, "Content-Md5")
require.NotContains(t, initResult.Headers, "Content-MD5")
require.Len(t, storage.presignedPutCalls, 1)
require.Equal(t, initResult.OSSObjectKey, storage.presignedPutCalls[0])
require.Equal(t, []string{""}, storage.presignedPutMD5s)
storage.existsByKey[initResult.OSSObjectKey] = true
storage.objectSizes[initResult.OSSObjectKey] = int64(len(content))
storage.objects[initResult.OSSObjectKey] = content
completeResult, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{
Platform: "darwin",
Arch: "arm64",
@@ -392,6 +396,34 @@ func TestDesktopClientReleaseDirectUploadRejectsSizeMismatch(t *testing.T) {
require.Contains(t, err.Error(), "direct_upload_size_mismatch")
}
func TestDesktopClientReleaseDirectUploadRejectsSHA256Mismatch(t *testing.T) {
t.Parallel()
sum := sha256.Sum256([]byte("release-package"))
sha256Hex := hex.EncodeToString(sum[:])
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, "client.zip")
storage := &desktopReleaseDownloadURLStorage{
existsByKey: map[string]bool{objectKey: true},
objects: map[string][]byte{objectKey: []byte("different-package")},
objectSizes: map[string]int64{objectKey: int64(len("different-package"))},
}
svc := NewDesktopClientReleaseService(nil, storage, nil)
_, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{
Platform: "darwin",
Arch: "arm64",
Channel: "stable",
Version: "1.2.3",
Kind: DesktopClientAssetKindUpdater,
OSSObjectKey: objectKey,
FileName: "client.zip",
FileSizeBytes: int64(len("different-package")),
SHA256: sha256Hex,
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid_sha256")
}
type desktopReleaseDownloadURLStorage struct {
publicURLCalls []string
downloadURLCalls []string
@@ -399,6 +431,7 @@ type desktopReleaseDownloadURLStorage struct {
objects map[string][]byte
objectSizes map[string]int64
presignedPutCalls []string
presignedPutMD5s []string
}
func (s *desktopReleaseDownloadURLStorage) Validate() error {
@@ -429,8 +462,23 @@ func (s *desktopReleaseDownloadURLStorage) PutReader(_ context.Context, objectKe
return nil
}
func (s *desktopReleaseDownloadURLStorage) GetBytes(context.Context, string) ([]byte, error) {
return nil, errors.New("not implemented")
func (s *desktopReleaseDownloadURLStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
if s.objects == nil {
return nil, fmt.Errorf("%w: %s", objectstorage.ErrObjectNotFound, objectKey)
}
content, ok := s.objects[objectKey]
if !ok {
return nil, fmt.Errorf("%w: %s", objectstorage.ErrObjectNotFound, objectKey)
}
return append([]byte(nil), content...), nil
}
func (s *desktopReleaseDownloadURLStorage) GetReader(_ context.Context, objectKey string) (io.ReadCloser, error) {
content, err := s.GetBytes(context.Background(), objectKey)
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(content)), nil
}
func (s *desktopReleaseDownloadURLStorage) Exists(_ context.Context, objectKey string) (bool, error) {
@@ -448,6 +496,7 @@ func (s *desktopReleaseDownloadURLStorage) PresignedPutURL(
ttl time.Duration,
) (*objectstorage.PresignedPutResult, error) {
s.presignedPutCalls = append(s.presignedPutCalls, objectKey)
s.presignedPutMD5s = append(s.presignedPutMD5s, contentMD5Base64)
headers := http.Header{}
headers.Set("Content-Type", contentType)
if contentMD5Base64 != "" {