diff --git a/README.md b/README.md index fbbbce1a..5e5bd10d 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,10 @@ agents: ``` Scheduler scripts may be inline JavaScript or a flat source mapping using -`provider: file`, `provider: http`, or `provider: git`. `config` and `up` -fetch mapped sources locally and send an inline snapshot to the daemon. Use +`provider: file`, `provider: http`, or `provider: git`. HTTP(S) script sources +must resolve to public addresses and are fetched without environment proxies; +this prevents SSRF through proxy-side DNS resolution. `config` and `up` fetch +mapped sources locally and send an inline snapshot to the daemon. Use either `scheduler.script` or `scheduler.triggers` in one scheduler. For example, load a scheduler script over HTTP: diff --git a/README.zh-CN.md b/README.zh-CN.md index d653c848..b7ff7ec2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -154,8 +154,7 @@ agents: ``` Scheduler 脚本可以是内联 JavaScript,也可以通过 `provider: file`、 -`provider: http` 或 `provider: git` 配置外部来源。`config` 和 `up` 会在本地 -读取外部脚本,并把内联内容快照发送给 daemon。例如,通过 HTTP 加载脚本: +`provider: http` 或 `provider: git` 配置外部来源。HTTP(S) 脚本源必须解析到公网地址,且不会使用环境代理;这是为了避免代理侧 DNS 解析绕过 SSRF 防护。`config` 和 `up` 会在本地读取外部脚本,并把内联内容快照发送给 daemon。例如,通过 HTTP 加载脚本: ```yaml agents: diff --git a/cmd/agent-compose/cli_project_workflow_test.go b/cmd/agent-compose/cli_project_workflow_test.go index 567f797e..b92433a1 100644 --- a/cmd/agent-compose/cli_project_workflow_test.go +++ b/cmd/agent-compose/cli_project_workflow_test.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "fmt" "github.com/chaitin/agent-compose/pkg/identity" agentcomposev2 "github.com/chaitin/agent-compose/proto/agentcompose/v2" "net/http" @@ -43,27 +42,23 @@ func TestResolveComposeAgentNameFromCandidates(t *testing.T) { } func TestUpScriptURLFetchFailureDoesNotApply(t *testing.T) { - sourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer sourceServer.Close() var daemonRequests int daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { daemonRequests++ w.WriteHeader(http.StatusInternalServerError) })) defer daemon.Close() - composePath := writeComposeFile(t, t.TempDir(), fmt.Sprintf(` + composePath := writeComposeFile(t, t.TempDir(), ` name: failed-script-url agents: reviewer: scheduler: script: provider: http - url: %s/scheduler.js -`, sourceServer.URL)) + url: http://127.0.0.1:1/scheduler.js +`) _, stderr, _, exitCode := executeCLICommand("up", "--file", composePath, "--host", daemon.URL) - if exitCode == 0 || !strings.Contains(stderr, "status 503") { + if exitCode == 0 || !strings.Contains(stderr, "prohibited address") { t.Fatalf("up stderr=%q exit=%d", stderr, exitCode) } if daemonRequests != 0 { diff --git a/cmd/agent-compose/cli_scheduler_test.go b/cmd/agent-compose/cli_scheduler_test.go index c6ee7449..c3b7acef 100644 --- a/cmd/agent-compose/cli_scheduler_test.go +++ b/cmd/agent-compose/cli_scheduler_test.go @@ -9,9 +9,7 @@ import ( "github.com/chaitin/agent-compose/pkg/compose" "github.com/chaitin/agent-compose/pkg/identity" agentcomposev2 "github.com/chaitin/agent-compose/proto/agentcompose/v2" - "io" "net/http" - "net/http/httptest" "os" "path/filepath" "reflect" @@ -25,11 +23,7 @@ import ( ) func TestConfigCommandExpandsSchedulerScriptURLs(t *testing.T) { - const script = `scheduler.interval("from-url", "1h");` - httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, script) - })) - defer httpServer.Close() + const script = `scheduler.interval("from-file", "1h");` for _, tc := range []struct { name string @@ -45,7 +39,6 @@ func TestConfigCommandExpandsSchedulerScriptURLs(t *testing.T) { } return "provider: file\n path: ./scripts/scheduler.js" }}, - {name: "HTTP", location: func(string) string { return "provider: http\n url: " + httpServer.URL + "/scheduler.js" }}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -69,11 +62,12 @@ agents: } func TestUpResolvesSchedulerScriptURLBeforeApply(t *testing.T) { - const script = `scheduler.interval("from-url", "1h");` - sourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, script) - })) - defer sourceServer.Close() + const script = `scheduler.interval("from-file", "1h");` + composeDir := t.TempDir() + scriptPath := filepath.Join(composeDir, "scheduler.js") + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + t.Fatal(err) + } var captured *agentcomposev2.ApplyProjectRequest daemon := newComposeServiceStubServer(t, composeServiceStubs{project: projectServiceStub{ @@ -84,15 +78,15 @@ func TestUpResolvesSchedulerScriptURLBeforeApply(t *testing.T) { }}) defer daemon.Close() - composePath := writeComposeFile(t, t.TempDir(), fmt.Sprintf(` -name: up-script-url + composePath := writeComposeFile(t, composeDir, ` +name: up-script-file agents: reviewer: scheduler: script: - provider: http - url: %s/scheduler.js -`, sourceServer.URL)) + provider: file + path: ./scheduler.js +`) _, expected, err := loadResolvedNormalizedCompose(context.Background(), cliOptions{ComposeFile: composePath}) if err != nil { t.Fatalf("load expected spec: %v", err) diff --git a/pkg/compose/script_source.go b/pkg/compose/script_source.go index b1de6fce..99daafa3 100644 --- a/pkg/compose/script_source.go +++ b/pkg/compose/script_source.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -35,16 +36,24 @@ func (f ScriptSourceResolverFunc) Resolve(ctx context.Context, source sources.So } type defaultScriptSourceResolver struct { - client *http.Client - env map[string]string + client *http.Client + env map[string]string + validateNetworkTarget func(context.Context, *url.URL) error } // NewDefaultScriptSourceResolver returns the bounded file and HTTP(S) resolver // used by CLI compose loading. func NewDefaultScriptSourceResolver(env map[string]string) ScriptSourceResolver { - resolver := &defaultScriptSourceResolver{env: env} + resolver := &defaultScriptSourceResolver{env: env, validateNetworkTarget: validateScriptNetworkTarget} + transport := http.DefaultTransport.(*http.Transport).Clone() + // Do not use environment-configured proxies here. A forward proxy resolves + // and fetches the target outside this process, so the daemon cannot enforce + // the same public-address SSRF policy on the actual connection. + transport.Proxy = nil + transport.DialContext = safeScriptDialContext resolver.client = &http.Client{ - Timeout: defaultScriptSourceTimeout, + Timeout: defaultScriptSourceTimeout, + Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) > maxScriptSourceRedirects { return fmt.Errorf("too many redirects (maximum %d)", maxScriptSourceRedirects) @@ -61,7 +70,7 @@ func NewDefaultScriptSourceResolver(env map[string]string) ScriptSourceResolver if len(via) > 0 && via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme == "http" { return errors.New("HTTPS redirect downgrade to HTTP is not allowed") } - return nil + return resolver.validateNetworkTarget(req.Context(), req.URL) }, } return resolver @@ -234,6 +243,9 @@ func readScriptFile(path string) ([]byte, error) { } func (r *defaultScriptSourceResolver) readHTTP(ctx context.Context, location *url.URL, source sources.Source) ([]byte, error) { + if err := r.validateNetworkTarget(ctx, location); err != nil { + return nil, fmt.Errorf("fetch script from %s: %w", redactedScriptURL(location), err) + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, location.String(), nil) if err != nil { return nil, fmt.Errorf("create script request for %s", redactedScriptURL(location)) @@ -241,7 +253,7 @@ func (r *defaultScriptSourceResolver) readHTTP(ctx context.Context, location *ur sources.ApplyHTTPAuthentication(req, source, r.env) resp, err := r.client.Do(req) if err != nil { - return nil, fmt.Errorf("fetch script from %s: %s", redactedScriptURL(location), sanitizeScriptFetchError(err)) + return nil, fmt.Errorf("fetch script from %s: %s%s", redactedScriptURL(location), sanitizeScriptFetchError(err), scriptProxyDisabledDiagnostic()) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -250,6 +262,120 @@ func (r *defaultScriptSourceResolver) readHTTP(ctx context.Context, location *ur return readLimitedScript(resp.Body) } +func scriptProxyDisabledDiagnostic() string { + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} { + if strings.TrimSpace(os.Getenv(name)) != "" { + return fmt.Sprintf(" (environment %s is intentionally disabled for SSRF protection)", name) + } + } + return "" +} + +func validateScriptNetworkTarget(ctx context.Context, target *url.URL) error { + host := strings.TrimSpace(target.Hostname()) + if host == "" { + return errors.New("script URL requires a valid host") + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return fmt.Errorf("resolve script URL host: %w", err) + } + if len(addresses) == 0 { + return errors.New("script URL host resolved to no addresses") + } + for _, address := range addresses { + if !isPublicScriptAddress(address.IP) { + return fmt.Errorf("script URL host resolves to prohibited address %s", address.IP) + } + } + return nil +} + +func safeScriptDialContext(ctx context.Context, network, address string) (net.Conn, error) { + dialer := net.Dialer{} + return safeScriptDialContextWithResolver(ctx, network, address, + func(ctx context.Context, host string) ([]net.IP, error) { + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + ips := make([]net.IP, 0, len(addresses)) + for _, address := range addresses { + ips = append(ips, address.IP) + } + return ips, nil + }, dialer.DialContext) +} + +func safeScriptDialContextWithResolver( + ctx context.Context, + network, address string, + lookup func(context.Context, string) ([]net.IP, error), + dial func(context.Context, string, string) (net.Conn, error), +) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse script endpoint: %w", err) + } + addresses, err := lookup(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve script endpoint: %w", err) + } + var dialErrs error + for _, candidate := range addresses { + if !isPublicScriptAddress(candidate) { + continue + } + conn, dialErr := dial(ctx, network, net.JoinHostPort(candidate.String(), port)) + if dialErr == nil { + return conn, nil + } + dialErrs = errors.Join(dialErrs, dialErr) + } + if dialErrs != nil { + return nil, dialErrs + } + return nil, errors.New("script endpoint has no permitted public address") +} + +func isPublicScriptAddress(ip net.IP) bool { + if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return false + } + for _, network := range prohibitedScriptNetworks { + if network.Contains(ip) { + return false + } + } + return true +} + +var prohibitedScriptNetworks = mustParseScriptNetworks([]string{ + "0.0.0.0/8", + "100.64.0.0/10", // RFC 6598 carrier-grade NAT. + "192.0.0.0/24", + "192.0.2.0/24", // RFC 5737 documentation. + "198.18.0.0/15", // RFC 2544 benchmarking. + "198.51.100.0/24", // RFC 5737 documentation. + "203.0.113.0/24", // RFC 5737 documentation. + "224.0.0.0/4", + "240.0.0.0/4", + "fec0::/10", // deprecated IPv6 site-local. + "2001:db8::/32", // IPv6 documentation. +}) + +func mustParseScriptNetworks(cidrs []string) []*net.IPNet { + networks := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Sprintf("invalid script network %q: %v", cidr, err)) + } + networks = append(networks, network) + } + return networks +} + func readLimitedScript(reader io.Reader) ([]byte, error) { data, err := io.ReadAll(io.LimitReader(reader, maxScriptSourceBytes+1)) if err != nil { diff --git a/pkg/compose/script_source_test.go b/pkg/compose/script_source_test.go index 5bb9e53c..30e7b929 100644 --- a/pkg/compose/script_source_test.go +++ b/pkg/compose/script_source_test.go @@ -3,13 +3,16 @@ package compose import ( "compress/gzip" "context" + "errors" "fmt" + "net" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" "time" @@ -17,6 +20,142 @@ import ( "github.com/chaitin/agent-compose/pkg/sources" ) +func newTestScriptSourceResolver() *defaultScriptSourceResolver { + resolver := NewDefaultScriptSourceResolver(nil).(*defaultScriptSourceResolver) + resolver.validateNetworkTarget = func(context.Context, *url.URL) error { return nil } + transport := resolver.client.Transport.(*http.Transport).Clone() + transport.DialContext = (&net.Dialer{}).DialContext + resolver.client.Transport = transport + return resolver +} + +func TestDefaultScriptSourceResolverRejectsPrivateHTTPTargets(t *testing.T) { + resolver := NewDefaultScriptSourceResolver(nil) + for _, location := range []string{ + "http://127.0.0.1/script.js", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.1/script.js", + "http://100.64.0.1/script.js", + "http://[::1]/script.js", + "http://0.0.0.1/script.js", + "http://198.18.0.1/script.js", + "http://240.0.0.1/script.js", + "http://[fec0::1]/script.js", + } { + if _, err := resolver.Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: location}); err == nil || !strings.Contains(err.Error(), "prohibited address") { + t.Fatalf("Resolve(%q) error = %v, want prohibited address error", location, err) + } + } +} + +func TestIsPublicScriptAddressRejectsSpecialUseNetworks(t *testing.T) { + for _, test := range []struct { + address string + public bool + }{ + {address: "8.8.8.8", public: true}, + {address: "100.64.0.1"}, + {address: "198.18.0.1"}, + {address: "240.0.0.1"}, + {address: "0.0.0.1"}, + {address: "fec0::1"}, + {address: "::1"}, + } { + if got := isPublicScriptAddress(net.ParseIP(test.address)); got != test.public { + t.Errorf("isPublicScriptAddress(%q) = %t, want %t", test.address, got, test.public) + } + } +} + +func TestDefaultScriptSourceResolverDisablesEnvironmentProxy(t *testing.T) { + proxyRequests := 0 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + proxyRequests++ + w.WriteHeader(http.StatusBadGateway) + })) + defer proxy.Close() + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("http_proxy", proxy.URL) + t.Setenv("HTTPS_PROXY", "") + t.Setenv("https_proxy", "") + t.Setenv("ALL_PROXY", "") + t.Setenv("all_proxy", "") + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + target := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("scheduler.interval('direct', 1000, main);")) + })) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen target: %v", err) + } + target.Listener = listener + target.Start() + defer target.Close() + targetAddress := listener.Addr().String() + targetPort := strings.TrimPrefix(target.URL, "http://127.0.0.1:") + + resolver := newTestScriptSourceResolver() + transport := resolver.client.Transport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, _, splitErr := net.SplitHostPort(address) + if splitErr == nil && host == "public.test" { + address = targetAddress + } + return (&net.Dialer{}).DialContext(ctx, network, address) + } + resolver.client.Transport = transport + data, err := resolver.Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: "http://public.test:" + targetPort + "/scheduler.js"}) + if err != nil { + t.Fatalf("Resolve with configured proxy = %v", err) + } + if !strings.Contains(string(data), "direct") || proxyRequests != 0 { + t.Fatalf("script data=%q proxyRequests=%d, want direct fetch and zero proxy requests", data, proxyRequests) + } +} + +func TestDefaultScriptSourceResolverDiagnosesDisabledEnvironmentProxy(t *testing.T) { + for _, proxyName := range []string{"HTTP_PROXY", "http_proxy"} { + t.Run(proxyName, func(t *testing.T) { + t.Setenv("HTTP_PROXY", "") + t.Setenv("http_proxy", "") + t.Setenv("HTTPS_PROXY", "") + t.Setenv("https_proxy", "") + t.Setenv("ALL_PROXY", "") + t.Setenv("all_proxy", "") + t.Setenv(proxyName, "http://proxy.invalid:8080") + server := httptest.NewServer(http.NotFoundHandler()) + location := server.URL + server.Close() + _, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: location}) + if err == nil || !strings.Contains(err.Error(), proxyName+" is intentionally disabled") { + t.Fatalf("Resolve error = %v, want %s disabled proxy diagnostic", err, proxyName) + } + }) + } +} + +func TestSafeScriptDialContextSkipsPrivateAddresses(t *testing.T) { + var dialed []string + _, err := safeScriptDialContextWithResolver( + context.Background(), "tcp", "mixed.example:443", + func(context.Context, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("8.8.8.8")}, nil + }, + func(_ context.Context, _, address string) (net.Conn, error) { + dialed = append(dialed, address) + return nil, errors.New("dial intentionally stopped") + }, + ) + if err == nil || !strings.Contains(err.Error(), "dial intentionally stopped") { + t.Fatalf("dial error = %v, want intentional dial error", err) + } + if !reflect.DeepEqual(dialed, []string{"8.8.8.8:443"}) { + t.Fatalf("dialed addresses = %v, want only public address", dialed) + } +} + func TestDefaultScriptSourceResolverReadsFilesAndFileURLs(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "scheduler script.js") @@ -27,7 +166,7 @@ func TestDefaultScriptSourceResolverReadsFilesAndFileURLs(t *testing.T) { if err := os.Symlink(path, link); err != nil { t.Fatal(err) } - resolver := NewDefaultScriptSourceResolver(nil) + resolver := newTestScriptSourceResolver() for _, location := range []string{path, (&url.URL{Scheme: "file", Path: link}).String()} { data, err := resolver.Resolve(context.Background(), sources.Source{Provider: sources.ProviderFile, Path: location}) if err != nil || !strings.Contains(string(data), "scheduler.interval") { @@ -65,7 +204,7 @@ func TestDefaultScriptSourceResolverReadsGitFile(t *testing.T) { t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output) } } - data, err := NewDefaultScriptSourceResolver(nil).Resolve(context.Background(), sources.Source{ + data, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{ Provider: sources.ProviderGit, URL: repository, Ref: "main", @@ -104,7 +243,7 @@ func TestDefaultScriptSourceResolverRejectsEscapingGitSymlink(t *testing.T) { } } - data, err := NewDefaultScriptSourceResolver(nil).Resolve(context.Background(), sources.Source{ + data, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{ Provider: sources.ProviderGit, URL: repository, Ref: "main", @@ -121,7 +260,7 @@ func TestDefaultScriptSourceResolverHTTPFailures(t *testing.T) { w.WriteHeader(http.StatusBadGateway) })) defer server.Close() - _, err := NewDefaultScriptSourceResolver(nil).Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL + "/scheduler.js?token=super-secret"}) + _, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL + "/scheduler.js?token=super-secret"}) if err == nil || !strings.Contains(err.Error(), "status 502") || strings.Contains(err.Error(), "super-secret") { t.Fatalf("Resolve error = %v", err) } @@ -134,7 +273,7 @@ func TestDefaultScriptSourceResolverHTTPFailures(t *testing.T) { defer server.Close() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() - _, err := NewDefaultScriptSourceResolver(nil).Resolve(ctx, sources.Source{Provider: sources.ProviderHTTP, URL: server.URL}) + _, err := newTestScriptSourceResolver().Resolve(ctx, sources.Source{Provider: sources.ProviderHTTP, URL: server.URL}) if err == nil || !strings.Contains(err.Error(), "deadline exceeded") { t.Fatalf("Resolve timeout error = %v", err) } @@ -147,7 +286,7 @@ func TestDefaultScriptSourceResolverHTTPFailures(t *testing.T) { http.Redirect(w, r, fmt.Sprintf("/%d", n+1), http.StatusFound) })) defer server.Close() - _, err := NewDefaultScriptSourceResolver(nil).Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL + "/0"}) + _, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL + "/0"}) if err == nil || !strings.Contains(err.Error(), "too many redirects") { t.Fatalf("Resolve redirects error = %v", err) } @@ -158,7 +297,7 @@ func TestDefaultScriptSourceResolverHTTPFailures(t *testing.T) { http.Redirect(w, r, "file:///tmp/scheduler.js", http.StatusFound) })) defer server.Close() - _, err := NewDefaultScriptSourceResolver(nil).Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL}) + _, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL}) if err == nil || !strings.Contains(err.Error(), "not supported") { t.Fatalf("Resolve redirect error = %v", err) } @@ -181,7 +320,10 @@ agents: provider: http url: %s `, location)) - normalized, err := Normalize(spec, NormalizeOptions{ResolveScriptURLs: true}) + normalized, err := Normalize(spec, NormalizeOptions{ + ResolveScriptURLs: true, + ScriptSourceResolver: newTestScriptSourceResolver(), + }) if err != nil { t.Fatalf("Normalize returned error: %v", err) } @@ -198,14 +340,22 @@ func TestDefaultScriptSourceResolverLimitsDecodedHTTPContent(t *testing.T) { _ = writer.Close() })) defer server.Close() - _, err := NewDefaultScriptSourceResolver(nil).Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL}) + _, err := newTestScriptSourceResolver().Resolve(context.Background(), sources.Source{Provider: sources.ProviderHTTP, URL: server.URL}) if err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("Resolve oversized content error = %v", err) } } -func TestDefaultScriptSourceResolverRejectsHTTPSDowngrade(t *testing.T) { +func TestDefaultScriptSourceResolverRejectsPrivateRedirect(t *testing.T) { resolver := NewDefaultScriptSourceResolver(nil).(*defaultScriptSourceResolver) + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/script.js", nil) + if err := resolver.client.CheckRedirect(req, nil); err == nil || !strings.Contains(err.Error(), "prohibited address") { + t.Fatalf("private redirect error = %v, want prohibited address error", err) + } +} + +func TestDefaultScriptSourceResolverRejectsHTTPSDowngrade(t *testing.T) { + resolver := newTestScriptSourceResolver() httpsRequest := httptest.NewRequest(http.MethodGet, "https://example.test/source", nil) httpRequest := httptest.NewRequest(http.MethodGet, "http://example.test/target", nil) err := resolver.client.CheckRedirect(httpRequest, []*http.Request{httpsRequest})