Files
liyuan 6134d413f5 fix(security): restore default-port-only HTTPS rule for upload URLs
Review finding on 1ba6bec8: relaxing ValidateDownloadURL to accept
non-default HTTPS ports also widened upload targets, because the upload
validator reuses it and only re-imposed the host trust set.

- validateUploadURL now also rejects non-default ports, making the
  upload trust boundary identical to the pre-removal policy (trusted
  DingTalk/OSS hosts on the default port)
- DingTalk/OSS upload endpoints always serve HTTPS on 443, so unlike
  dedicated-deployment downloads there is no legitimate non-default
  port scenario for uploads
- regressions: trusted-host:8443 upload targets are rejected for both
  public-cloud and dedicated hosts
2026-08-25 20:05:06 +08:00

129 lines
4.7 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const defaultUploadLimit = int64(5 << 30)
var (
newUploadHTTPClient = secureHTTPClient
validateUploadURL = func(raw string) error {
parsed, err := ValidateDownloadURL(raw)
if err != nil {
return err
}
// Uploads publish local file bytes outward, so unlike host-agnostic
// downloads the target keeps the pre-existing DingTalk/OSS trust set.
if !trustedUploadHost(parsed.Hostname()) {
return fmt.Errorf("上传地址域名 %q 不属于受信任的钉钉或 OSS 域名", parsed.Hostname())
}
// Ditto for the port: DingTalk/OSS upload endpoints always serve HTTPS
// on the default port, so a non-default port is anomalous for uploads
// even though dedicated-deployment download domains legitimately use
// one. Keep the upload boundary identical to the pre-removal policy.
if port := parsed.Port(); port != "" && port != "443" {
return fmt.Errorf("上传地址只允许 HTTPS 默认端口")
}
return nil
}
openUploadFile = os.Open
newUploadRequest = http.NewRequestWithContext
)
// trustedUploadHost keeps the upload host trust boundary that existed before
// the download allowlist removal: public DingTalk and Aliyun OSS domains
// only. Downloads may be host-agnostic because their URL and credential
// headers are issued together by the authenticated service response, but
// uploads send local file bytes outward and stay statically bounded.
func trustedUploadHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
}
// UploadResult records only non-sensitive transfer facts. The signed URL is
// deliberately never returned.
type UploadResult struct {
SizeBytes int64
Attempts int
}
// PutFile uploads a regular local file to an exact trusted pre-signed HTTPS
// endpoint. Redirects are rejected so file bytes and credentials cannot move to
// a second origin. Transient failures reuse the same verified open file so a
// path replacement cannot change the bytes sent by a later attempt.
func PutFile(ctx context.Context, rawURL, path string, maxBytes int64) (UploadResult, error) {
client := newUploadHTTPClient()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return putFileWithClient(ctx, rawURL, path, maxBytes, client, validateUploadURL)
}
func putFileWithClient(ctx context.Context, rawURL, path string, maxBytes int64, client *http.Client, validate func(string) error) (UploadResult, error) {
if validate == nil {
return UploadResult{}, fmt.Errorf("upload URL validator is nil")
}
if err := validate(rawURL); err != nil {
return UploadResult{}, err
}
if maxBytes <= 0 {
maxBytes = defaultUploadLimit
}
file, err := openUploadFile(path)
if err != nil {
return UploadResult{}, fmt.Errorf("打开上传文件失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return UploadResult{}, fmt.Errorf("读取上传文件失败: %w", err)
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
return UploadResult{}, fmt.Errorf("上传文件必须是 1..%d 字节的普通文件", maxBytes)
}
result := UploadResult{SizeBytes: info.Size()}
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
result.Attempts = attempt
reader := io.NewSectionReader(file, 0, info.Size())
request, requestErr := newUploadRequest(ctx, http.MethodPut, rawURL, reader)
if requestErr != nil {
return UploadResult{}, fmt.Errorf("创建上传请求失败: %w", requestErr)
}
request.ContentLength = info.Size()
response, callErr := client.Do(request)
if callErr == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
bodyCloseErr := response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 && bodyCloseErr == nil {
return result, nil
}
if response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
return result, fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
}
lastErr = fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
} else {
lastErr = callErr
}
if attempt < 3 {
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return result, ctx.Err()
case <-timer.C:
}
}
}
return result, fmt.Errorf("上传在 %d 次尝试后失败: %w", result.Attempts, lastErr)
}