fix(acceptance): 允许受信模拟源的媒体物化

验收任务物化最终媒体时,仅允许访问 Acceptance Run 登记的协议模拟器精确 origin;其他私网、协议、端口及带 userinfo 的地址继续由 SSRF 防护拒绝。\n\n验证:Go 全量测试、go vet、gofmt 和 git diff --check 通过。
This commit is contained in:
2026-07-31 21:39:19 +08:00
parent add99e5421
commit 015ff8ea6c
3 changed files with 57 additions and 8 deletions
@@ -281,7 +281,7 @@ func (s *Service) readVideoInputImageBytes(ctx context.Context, source string) (
if err != nil {
return nil, fmt.Errorf("图片地址无效")
}
httpClient := generatedAssetHTTPClient(false)
httpClient := generatedAssetHTTPClient(false, false)
httpClient.Timeout = 10 * time.Second
httpClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if len(via) >= 3 {
+28 -7
View File
@@ -129,6 +129,13 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
// override scoped to the authenticated run so normal production tasks retain
// the configured file-storage policy.
policy = generatedAssetUploadPolicyForAcceptanceRun(policy, acceptanceRunID)
acceptanceEmulatorBaseURL := ""
if strings.TrimSpace(acceptanceRunID) != "" {
acceptanceEmulatorBaseURL, _, err = s.store.AcceptanceCandidateOverride(ctx, acceptanceRunID)
if err != nil {
return nil, &clients.ClientError{Code: "acceptance_run_inactive", Message: err.Error(), Retryable: false}
}
}
if policy.LocalizeInlineMedia {
next, _, err := s.materializeLocalBinaryResult(ctx, taskID, result)
if err != nil {
@@ -210,7 +217,7 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels)
sourceKey = decision.Inline.SourceKey
} else {
upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels)
upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels, acceptanceEmulatorBaseURL)
sourceKey = decision.URL.SourceKey
}
if err != nil {
@@ -687,8 +694,8 @@ func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset
return upload, contentType, kind, "upload_inline_media", err
}
func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, asset *generatedURLAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, string, error) {
payload, contentType, err := s.readGeneratedURLAsset(ctx, asset)
func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, asset *generatedURLAsset, index int, channels []store.FileStorageChannel, acceptanceEmulatorBaseURL string) (map[string]any, string, string, string, error) {
payload, contentType, err := s.readGeneratedURLAsset(ctx, asset, acceptanceEmulatorBaseURL)
if err != nil {
return nil, "", "", "", err
}
@@ -834,7 +841,7 @@ func sanitizeFileExtension(value string) string {
return value
}
func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURLAsset) ([]byte, string, error) {
func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURLAsset, acceptanceEmulatorBaseURL string) ([]byte, string, error) {
fetchURL, err := s.generatedAssetFetchURL(asset.URL)
if err != nil {
return nil, "", err
@@ -844,7 +851,8 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
return nil, "", err
}
allowLocal := strings.HasPrefix(strings.TrimSpace(asset.URL), "/")
resp, err := generatedAssetHTTPClient(allowLocal).Do(req)
allowAcceptanceOrigin := sameHTTPOrigin(asset.URL, acceptanceEmulatorBaseURL)
resp, err := generatedAssetHTTPClient(allowLocal, allowAcceptanceOrigin).Do(req)
if err != nil {
return nil, "", &clients.ClientError{Code: "upload_source_fetch_failed", Message: err.Error(), Retryable: true}
}
@@ -873,7 +881,7 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
return payload, strings.TrimSpace(strings.Split(contentType, ";")[0]), nil
}
func generatedAssetHTTPClient(allowLocal bool) *http.Client {
func generatedAssetHTTPClient(allowLocal bool, allowAcceptanceOrigin bool) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
@@ -886,7 +894,8 @@ func generatedAssetHTTPClient(allowLocal bool) *http.Client {
return nil, errors.New("generated media DNS resolution failed")
}
for _, address := range addresses {
if generatedAssetBlockedAddress(address.IP) && !(allowLocal && address.IP.IsLoopback()) {
if generatedAssetBlockedAddress(address.IP) &&
!(allowLocal && address.IP.IsLoopback()) && !allowAcceptanceOrigin {
return nil, errors.New("generated media resolved to a blocked network")
}
}
@@ -910,6 +919,18 @@ func generatedAssetHTTPClient(allowLocal bool) *http.Client {
}
}
func sameHTTPOrigin(rawURL string, allowedBaseURL string) bool {
raw, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || raw.User != nil || (raw.Scheme != "http" && raw.Scheme != "https") || raw.Host == "" {
return false
}
allowed, err := url.Parse(strings.TrimSpace(allowedBaseURL))
if err != nil || allowed.User != nil || (allowed.Scheme != "http" && allowed.Scheme != "https") || allowed.Host == "" {
return false
}
return strings.EqualFold(raw.Scheme, allowed.Scheme) && strings.EqualFold(raw.Host, allowed.Host)
}
func generatedAssetBlockedAddress(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
+28
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -301,6 +303,32 @@ func TestAcceptanceRunForcesURLMaterializationOnlyForAcceptanceTask(t *testing.T
}
}
func TestAcceptanceGeneratedMediaAllowsOnlyExactEmulatorOrigin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'})
}))
defer server.Close()
service := &Service{}
asset := &generatedURLAsset{URL: server.URL + "/media/result.mp4", Kind: "video"}
payload, contentType, err := service.readGeneratedURLAsset(t.Context(), asset, server.URL+"/v1")
if err != nil {
t.Fatalf("read exact acceptance origin: %v", err)
}
if len(payload) != 12 || contentType != "video/mp4" {
t.Fatalf("payload=%d contentType=%q", len(payload), contentType)
}
for _, allowed := range []string{
strings.Replace(server.URL, "http://", "https://", 1),
server.URL + "1",
strings.Replace(server.URL, "http://", "http://user@", 1),
} {
if sameHTTPOrigin(asset.URL, allowed) {
t.Fatalf("unexpected origin match: %q", allowed)
}
}
}
func TestFinalizeGeneratedAssetsUploadsNestedInlineBinaryUnderDefaultPolicy(t *testing.T) {
storageDir := t.TempDir()
service := &Service{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}