diff --git a/apps/api/internal/runner/input_image_normalizer.go b/apps/api/internal/runner/input_image_normalizer.go index f6616a9..1746809 100644 --- a/apps/api/internal/runner/input_image_normalizer.go +++ b/apps/api/internal/runner/input_image_normalizer.go @@ -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 { diff --git a/apps/api/internal/runner/upload.go b/apps/api/internal/runner/upload.go index 8025fac..7c505a3 100644 --- a/apps/api/internal/runner/upload.go +++ b/apps/api/internal/runner/upload.go @@ -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() } diff --git a/apps/api/internal/runner/upload_test.go b/apps/api/internal/runner/upload_test.go index 0bf7778..4abc756 100644 --- a/apps/api/internal/runner/upload_test.go +++ b/apps/api/internal/runner/upload_test.go @@ -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}}