Guard vetting agent HTTP tools against SSRF
The third-party vetting agent runs a suite of HTTP "security" tools on
the internal worker network against a caller-supplied URL that is only
validated for length and charset, not host. Several tools reached
internal, loopback, and link-local addresses:
- analyze_csp used a bare http.Client with no host validation, no
redirect control, and no rebinding-safe transport, reflecting the
target's CSP header back to the caller.
- check_security_headers, fetch_robots_txt, and fetch_sitemap
validated only the initial host, then followed 3xx redirects with an
ordinary client, yielding full-read SSRF via a redirect to an
internal address.
- check_cors validated the URL but still dialed through an ordinary
transport, leaving it exposed to DNS-rebinding TOCTOU.
Route every one of these clients through the house-standard
httpclient.DefaultPooledClient(WithSSRFProtection()), which rejects
dials to loopback, private, CGNAT, link-local, ULA, IPv4-mapped, and
reserved ranges on the resolved peer IP at connect time (defeating DNS
rebinding on every redirect hop) and refuses cross-origin redirects.
download_pdf moves onto the same client, and the now-unused local
netcheck.NewPinnedTransport is removed. analyze_csp also gains an
up-front ValidatePublicURL check for a clean early error and scheme
enforcement.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -27,8 +27,8 @@ import (
|
|||||||
|
|
||||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/agent"
|
"go.probo.inc/probo/pkg/agent"
|
||||||
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -44,10 +44,8 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func DownloadPDFTool() agent.Tool {
|
func DownloadPDFTool() agent.Tool {
|
||||||
client := &http.Client{
|
client := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
Timeout: 30 * time.Second,
|
client.Timeout = 30 * time.Second
|
||||||
Transport: netcheck.NewPinnedTransport(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return agent.FunctionTool(
|
return agent.FunctionTool(
|
||||||
"download_pdf",
|
"download_pdf",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/agent"
|
"go.probo.inc/probo/pkg/agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func FetchRobotsTxtTool() agent.Tool {
|
func FetchRobotsTxtTool() agent.Tool {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
|
client.Timeout = 10 * time.Second
|
||||||
|
|
||||||
return agent.FunctionTool(
|
return agent.FunctionTool(
|
||||||
"fetch_robots_txt",
|
"fetch_robots_txt",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/agent"
|
"go.probo.inc/probo/pkg/agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func FetchSitemapTool() agent.Tool {
|
func FetchSitemapTool() agent.Tool {
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
client := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
|
client.Timeout = 15 * time.Second
|
||||||
|
|
||||||
return agent.FunctionTool(
|
return agent.FunctionTool(
|
||||||
"fetch_sitemap",
|
"fetch_sitemap",
|
||||||
|
|||||||
@@ -17,10 +17,8 @@
|
|||||||
package netcheck
|
package netcheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,41 +86,3 @@ func ValidatePublicDomain(domain string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPinnedTransport returns an *http.Transport with a custom DialContext that
|
|
||||||
// resolves the target host once, validates all resolved IPs with IsPublicIP,
|
|
||||||
// and dials the validated IP directly. This prevents DNS rebinding attacks
|
|
||||||
// where the first lookup returns a public IP but a subsequent lookup (at
|
|
||||||
// connection time) returns a private IP.
|
|
||||||
func NewPinnedTransport() *http.Transport {
|
|
||||||
return &http.Transport{
|
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
host, port, err := net.SplitHostPort(addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot parse address: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot resolve host: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ips) == 0 {
|
|
||||||
return nil, fmt.Errorf("cannot resolve host: no addresses found")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ip := range ips {
|
|
||||||
if !IsPublicIP(ip.IP) {
|
|
||||||
return nil, fmt.Errorf("cannot connect to non-public IP %s", ip.IP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dial the first validated IP directly to prevent DNS rebinding.
|
|
||||||
pinnedAddr := net.JoinHostPort(ips[0].IP.String(), port)
|
|
||||||
|
|
||||||
var d net.Dialer
|
|
||||||
|
|
||||||
return d.DialContext(ctx, network, pinnedAddr)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/agent"
|
"go.probo.inc/probo/pkg/agent"
|
||||||
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,12 @@ func splitTrimmed(s, sep string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CheckCORSTool() agent.Tool {
|
func CheckCORSTool() agent.Tool {
|
||||||
|
client := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
|
client.Timeout = 10 * time.Second
|
||||||
|
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
|
||||||
return agent.FunctionTool(
|
return agent.FunctionTool(
|
||||||
"check_cors",
|
"check_cors",
|
||||||
"Send a CORS preflight (OPTIONS) request to a URL with a given Origin and analyze the Access-Control-* response headers, flagging wildcard origins and origin reflection.",
|
"Send a CORS preflight (OPTIONS) request to a URL with a given Origin and analyze the Access-Control-* response headers, flagging wildcard origins and origin reflection.",
|
||||||
@@ -75,13 +82,6 @@ func CheckCORSTool() agent.Tool {
|
|||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
req, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodOptions,
|
http.MethodOptions,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/agent"
|
"go.probo.inc/probo/pkg/agent"
|
||||||
|
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -73,11 +75,20 @@ func parseCSPDirectives(raw string) []cspDirective {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AnalyzeCSPTool() agent.Tool {
|
func AnalyzeCSPTool() agent.Tool {
|
||||||
|
client := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
|
client.Timeout = 10 * time.Second
|
||||||
|
|
||||||
return agent.FunctionTool(
|
return agent.FunctionTool(
|
||||||
"analyze_csp",
|
"analyze_csp",
|
||||||
"Analyze the Content-Security-Policy header for a URL, parsing directives and flagging unsafe patterns like unsafe-eval, unsafe-inline, and wildcard sources.",
|
"Analyze the Content-Security-Policy header for a URL, parsing directives and flagging unsafe patterns like unsafe-eval, unsafe-inline, and wildcard sources.",
|
||||||
func(ctx context.Context, p cspParams) (agent.ToolResult, error) {
|
func(ctx context.Context, p cspParams) (agent.ToolResult, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
if err := netcheck.ValidatePublicURL(p.URL); err != nil {
|
||||||
|
return agent.ResultJSON(
|
||||||
|
cspResult{
|
||||||
|
ErrorDetail: fmt.Sprintf("URL not allowed: %s", err),
|
||||||
|
},
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/agent"
|
"go.probo.inc/probo/pkg/agent"
|
||||||
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,15 @@ func headersFromResponse(resp *http.Response) headersResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CheckSecurityHeadersTool() agent.Tool {
|
func CheckSecurityHeadersTool() agent.Tool {
|
||||||
|
client := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
|
client.Timeout = 10 * time.Second
|
||||||
|
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
followClient := httpclient.DefaultPooledClient(httpclient.WithSSRFProtection())
|
||||||
|
followClient.Timeout = 10 * time.Second
|
||||||
|
|
||||||
return agent.FunctionTool(
|
return agent.FunctionTool(
|
||||||
"check_security_headers",
|
"check_security_headers",
|
||||||
"Check security-related HTTP headers for a URL (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-*-Policy). Also checks if HTTP redirects to HTTPS.",
|
"Check security-related HTTP headers for a URL (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-*-Policy). Also checks if HTTP redirects to HTTPS.",
|
||||||
@@ -87,13 +97,6 @@ func CheckSecurityHeadersTool() agent.Tool {
|
|||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check the HTTP version to detect HTTP→HTTPS redirect.
|
// First check the HTTP version to detect HTTP→HTTPS redirect.
|
||||||
redirectsToHTTPS := false
|
redirectsToHTTPS := false
|
||||||
|
|
||||||
@@ -129,8 +132,6 @@ func CheckSecurityHeadersTool() agent.Tool {
|
|||||||
httpsParsed.Scheme = "https"
|
httpsParsed.Scheme = "https"
|
||||||
httpsURL := httpsParsed.String()
|
httpsURL := httpsParsed.String()
|
||||||
|
|
||||||
followClient := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
|
|
||||||
httpsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpsURL, nil)
|
httpsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpsURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return agent.ResultJSON(
|
return agent.ResultJSON(
|
||||||
|
|||||||
Reference in New Issue
Block a user