diff --git a/plugins/host/http.go b/plugins/host/http.go index b61361c5..96e832a0 100644 --- a/plugins/host/http.go +++ b/plugins/host/http.go @@ -4,11 +4,12 @@ import "context" // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers,omitempty"` - Body []byte `json:"body,omitempty"` - TimeoutMs int32 `json:"timeoutMs,omitempty"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + NoFollowRedirects bool `json:"noFollowRedirects,omitempty"` + Body []byte `json:"body,omitempty"` + TimeoutMs int32 `json:"timeoutMs,omitempty"` } // HTTPResponse represents the response from an outbound HTTP request. diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 4dc8a2b9..f1d64deb 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -22,6 +22,12 @@ const ( httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB ) +// contextKey is used for per-request redirect control via context. +type contextKey struct{} + +// noFollowRedirectsKey signals the CheckRedirect callback to stop following redirects. +var noFollowRedirectsKey = contextKey{} + // httpServiceImpl implements host.HTTPService. type httpServiceImpl struct { pluginName string @@ -44,6 +50,9 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI // Timeout is set per-request via context deadline, not here. // CheckRedirect validates hosts and enforces redirect limits. CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.Context().Value(noFollowRedirectsKey) != nil { + return http.ErrUseLastResponse + } if len(via) >= httpClientMaxRedirects { log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) return http.ErrUseLastResponse @@ -80,6 +89,11 @@ func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (* ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + // Signal CheckRedirect to not follow redirects for this request + if request.NoFollowRedirects { + ctx = context.WithValue(ctx, noFollowRedirectsKey, true) + } + // Build request body method := strings.ToUpper(request.Method) var body io.Reader diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 29796b05..27e92d59 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -311,6 +311,26 @@ var _ = Describe("httpServiceImpl", func() { Expect(err.Error()).To(ContainSubstring("context canceled")) }) + It("should not follow redirects when NoFollowRedirects is true", func() { + dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("final")) + })) + defer dest.Close() + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, dest.URL, http.StatusFound) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + NoFollowRedirects: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(302))) + Expect(resp.Headers["Location"]).To(Equal(dest.URL)) + Expect(string(resp.Body)).ToNot(Equal("final")) + }) + It("should send request headers", func() { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(r.Header.Get("X-Custom"))) diff --git a/plugins/pdk/go/host/nd_host_http.go b/plugins/pdk/go/host/nd_host_http.go index d77db476..d999d371 100644 --- a/plugins/pdk/go/host/nd_host_http.go +++ b/plugins/pdk/go/host/nd_host_http.go @@ -17,11 +17,12 @@ import ( // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` - TimeoutMs int32 `json:"timeoutMs"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + NoFollowRedirects bool `json:"noFollowRedirects"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` } // HTTPResponse represents the HTTPResponse data structure. diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index b5d1eee7..2f15a91a 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -13,11 +13,12 @@ import "github.com/stretchr/testify/mock" // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` - TimeoutMs int32 `json:"timeoutMs"` + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + NoFollowRedirects bool `json:"noFollowRedirects"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` } // HTTPResponse represents the HTTPResponse data structure. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index d3bb2d32..1c44cd2f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -38,6 +38,8 @@ pub struct HTTPRequest { #[serde(default)] pub headers: std::collections::HashMap, #[serde(default)] + pub no_follow_redirects: bool, + #[serde(default)] #[serde(with = "base64_bytes")] pub body: Vec, #[serde(default)]