@@ -40,7 +40,7 @@ func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) {
|
||||
transport := &oauth2Transport{
|
||||
token: c.APIKey,
|
||||
tokenType: "Bearer",
|
||||
underlying: httpclient.DefaultPooledTransport(),
|
||||
underlying: httpclient.DefaultPooledTransport(httpclient.WithSSRFProtection()),
|
||||
}
|
||||
return &http.Client{Transport: transport}, nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ type (
|
||||
ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google)
|
||||
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
|
||||
SupportsIncrementalAuth bool
|
||||
|
||||
// HTTPClient is used for the OAuth2 token-exchange request
|
||||
// issued from CompleteWithState. It must be set by callers;
|
||||
// ApplyProviderDefaults assigns an SSRF-protected client for
|
||||
// production use. Tests may inject a loopback-friendly one.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
OAuth2State struct {
|
||||
@@ -208,7 +214,7 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
tokenResp, err := http.DefaultClient.Do(tokenRequest)
|
||||
tokenResp, err := c.HTTPClient.Do(tokenRequest)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot post token URL: %w", err)
|
||||
}
|
||||
@@ -363,7 +369,14 @@ func (c *OAuth2Connection) Client(ctx context.Context) (*http.Client, error) {
|
||||
|
||||
// ClientWithOptions returns an HTTP client with the given options.
|
||||
// Use this to add logging and tracing to the HTTP client.
|
||||
//
|
||||
// SSRF protection is always enabled: the underlying connector URL
|
||||
// (for example a 1Password SCIM bridge URL) is customer-supplied,
|
||||
// so dials to private, loopback, or other reserved address ranges
|
||||
// are refused. Hardcoded provider hosts on public IPs are
|
||||
// unaffected.
|
||||
func (c *OAuth2Connection) ClientWithOptions(ctx context.Context, opts ...httpclient.Option) (*http.Client, error) {
|
||||
opts = append(opts, httpclient.WithSSRFProtection())
|
||||
transport := &oauth2Transport{
|
||||
token: c.AccessToken,
|
||||
tokenType: c.TokenType,
|
||||
@@ -389,6 +402,11 @@ func (c *OAuth2Connection) RefreshableClient(ctx context.Context, cfg OAuth2Refr
|
||||
return c.ClientWithOptions(ctx, opts...)
|
||||
}
|
||||
|
||||
// All HTTP traffic on this path (token refresh + API calls)
|
||||
// must reject private/loopback/reserved peer IPs because the
|
||||
// configured TokenURL or API host can be customer-influenced.
|
||||
opts = append(opts, httpclient.WithSSRFProtection())
|
||||
|
||||
// Determine auth style based on TokenEndpointAuth
|
||||
authStyle := oauth2.AuthStyleInParams
|
||||
switch cfg.TokenEndpointAuth {
|
||||
@@ -462,6 +480,10 @@ func (c *OAuth2Connection) clientCredentialsClient(ctx context.Context, opts ...
|
||||
return c.ClientWithOptions(ctx, opts...)
|
||||
}
|
||||
|
||||
// TokenURL is stored from customer-supplied connector settings;
|
||||
// reject dials to private/loopback/reserved peer IPs.
|
||||
opts = append(opts, httpclient.WithSSRFProtection())
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "client_credentials")
|
||||
if c.Scope != "" {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/statelesstoken"
|
||||
)
|
||||
@@ -228,7 +229,9 @@ func TestClientCredentialsClient(t *testing.T) {
|
||||
TokenURL: server.URL,
|
||||
}
|
||||
|
||||
client, err := conn.clientCredentialsClient(context.Background())
|
||||
// httptest binds to loopback, which the SSRF-protected default
|
||||
// transport refuses; relax just for this test.
|
||||
client, err := conn.clientCredentialsClient(context.Background(), httpclient.WithSSRFAllowLoopback())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
|
||||
@@ -518,6 +521,9 @@ func TestCompleteWithState_ScopeFallback(t *testing.T) {
|
||||
RedirectURI: "https://example.com/cb",
|
||||
AuthURL: "https://provider.example.com/authorize",
|
||||
TokenURL: server.URL,
|
||||
// httptest binds to loopback, which the SSRF-protected
|
||||
// default client refuses; inject a permissive client.
|
||||
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
|
||||
}
|
||||
|
||||
orgID := gid.New(gid.NewTenantID(), 0)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
package connector
|
||||
|
||||
import "go.gearno.de/kit/httpclient"
|
||||
|
||||
// CallbackPath is the HTTP path for the OAuth2 callback endpoint.
|
||||
const CallbackPath = "/api/console/v1/connectors/complete"
|
||||
|
||||
@@ -87,9 +89,11 @@ var (
|
||||
|
||||
// ApplyProviderDefaults sets the redirect URI and applies static provider
|
||||
// defaults (auth URL, token URL, extra params, token endpoint auth) onto
|
||||
// an OAuth2Connector. Call this before registering the connector.
|
||||
// an OAuth2Connector, and wires an SSRF-protected HTTP client for the
|
||||
// token exchange request. Call this before registering the connector.
|
||||
func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connector) {
|
||||
c.RedirectURI = redirectURI
|
||||
c.HTTPClient = httpclient.DefaultClient(httpclient.WithSSRFProtection())
|
||||
|
||||
if def, ok := providerDefinitions[provider]; ok {
|
||||
c.AuthURL = def.AuthURL
|
||||
|
||||
@@ -88,7 +88,7 @@ func NewSender(pg *pg.Client, logger *log.Logger, cfg Config) *Sender {
|
||||
return &Sender{
|
||||
pg: pg,
|
||||
logger: logger,
|
||||
httpClient: httpclient.DefaultPooledClient(httpclient.WithLogger(logger)),
|
||||
httpClient: httpclient.DefaultPooledClient(httpclient.WithLogger(logger), httpclient.WithSSRFProtection()),
|
||||
encryptionKey: cfg.EncryptionKey,
|
||||
host: cfg.Host,
|
||||
cacheCreatedAt: time.Now(),
|
||||
|
||||
Reference in New Issue
Block a user