fix(acceptance): 允许受信模拟源的媒体物化
验收任务物化最终媒体时,仅允许访问 Acceptance Run 登记的协议模拟器精确 origin;其他私网、协议、端口及带 userinfo 的地址继续由 SSRF 防护拒绝。\n\n验证:Go 全量测试、go vet、gofmt 和 git diff --check 通过。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user