apply SSRF guard to all admin-configured outbound URLs

Move the webhook SSRF guard into a shared internal/ssrf package and wire it
into every place the server fetches an admin-set URL: webhooks, OIDC discovery,
the AI provider base URL, and custom AI tool calls. Add a global [ssrf] config
block, off by default with an allowed_cidrs bypass, so single-tenant self-hosters
keep reaching internal hosts while multi-tenant or hosted deploys can turn it on.
The old [webhook] allowed_hosts key is still read for backward compat and folds
into the guard.
This commit is contained in:
Abhinav Raut
2026-07-13 03:13:56 +05:30
parent d02b65f2bd
commit 9566dcbf4d
11 changed files with 160 additions and 66 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ func (m *Manager) RunAgentWithTools(ctx context.Context, systemPrompt string, hi
if err != nil {
return "", err
}
client := NewOpenAIClient(cfg, m.lo)
client := NewOpenAIClient(cfg, m.lo, m.dialControl)
if instructions := strings.TrimSpace(cfg.Instructions); allowedToolIDs == nil && instructions != "" && systemPrompt != "" {
systemPrompt += "\n\nWorkspace admin instructions (follow these, they take precedence on tone and format):\n" + instructions
}
+21 -2
View File
@@ -8,14 +8,18 @@ import (
"embed"
"encoding/json"
"errors"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/crypto"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/ssrf"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
@@ -45,6 +49,8 @@ type Manager struct {
reconcileMu sync.Mutex
snippetGenMu sync.Mutex
snippetGen map[int]uint64
dialControl ssrf.Control
toolClient *http.Client
}
// Opts contains options for initializing the Manager.
@@ -53,6 +59,7 @@ type Opts struct {
I18n *i18n.I18n
Lo *logf.Logger
EncryptionKey string
DialControl ssrf.Control
}
// ProviderConfigView is the sanitized provider config returned to admins, with the API key dummy-masked.
@@ -103,6 +110,18 @@ func New(opts Opts) (*Manager, error) {
chunkCfg: stringutil.DefaultChunkConfig(),
index: newEmbeddingIndex(),
snippetGen: make(map[int]uint64),
dialControl: opts.DialControl,
toolClient: &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
Control: opts.DialControl,
}).DialContext,
ForceAttemptHTTP2: true,
},
},
}
m.chunkCfg.Logger = opts.Lo
m.chunkCfg.TokenizerFunc = newTokenCounter(opts.Lo)
@@ -271,7 +290,7 @@ func (m *Manager) TestProviderConfig(providerType string, in models.ProviderConf
}
cfg.APIKey = stored.APIKey
}
client := NewOpenAIClient(cfg, m.lo)
client := NewOpenAIClient(cfg, m.lo, m.dialControl)
if providerType == models.ProviderTypeCompletion {
if _, err := client.SendPrompt(context.Background(), models.PromptPayload{SystemPrompt: "You are a connection test.", UserPrompt: "Reply with OK."}); err != nil {
@@ -358,7 +377,7 @@ func (m *Manager) getProviderClient(providerType string) (ProviderClient, error)
if err != nil {
return nil, err
}
return NewOpenAIClient(cfg, m.lo), nil
return NewOpenAIClient(cfg, m.lo, m.dialControl), nil
}
func (m *Manager) providerError(err error) error {
+16 -4
View File
@@ -6,12 +6,14 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/ssrf"
"github.com/zerodha/logf"
)
@@ -53,15 +55,25 @@ type OpenAIClient struct {
client *http.Client
}
func NewOpenAIClient(cfg models.ProviderConfig, lo *logf.Logger) *OpenAIClient {
func NewOpenAIClient(cfg models.ProviderConfig, lo *logf.Logger, dialControl ssrf.Control) *OpenAIClient {
if cfg.BaseURL == "" {
cfg.BaseURL = defaultOpenAIBaseURL
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &OpenAIClient{
cfg: cfg,
lo: lo,
client: &http.Client{Timeout: 60 * time.Second},
cfg: cfg,
lo: lo,
client: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
Control: dialControl,
}).DialContext,
ForceAttemptHTTP2: true,
},
},
}
}
+3 -6
View File
@@ -9,7 +9,6 @@ import (
"net/http"
neturl "net/url"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/crypto"
@@ -38,8 +37,6 @@ var (
// allowedToolMethods are the HTTP methods a custom tool may use: GET reads, POST writes.
allowedToolMethods = map[string]bool{http.MethodGet: true, http.MethodPost: true}
toolHTTPClient = &http.Client{Timeout: 20 * time.Second}
searchArticlesParams = types.JSONText(`{
"type": "object",
"properties": {
@@ -126,12 +123,12 @@ type httpTool struct {
tctx ToolContext
}
func newHTTPTool(t models.Tool, encryptionKey string, lo *logf.Logger, tctx ToolContext) *httpTool {
func newHTTPTool(t models.Tool, encryptionKey string, lo *logf.Logger, client *http.Client, tctx ToolContext) *httpTool {
return &httpTool{
tool: t,
encryptionKey: encryptionKey,
lo: lo,
client: toolHTTPClient,
client: client,
tctx: tctx,
}
}
@@ -250,7 +247,7 @@ func (m *Manager) buildToolRegistry(tctx ToolContext, allowedToolIDs []int, incl
m.lo.Warn("skipping custom tool that collides with a built-in tool", "name", ct.Name)
continue
}
ht := newHTTPTool(ct, m.encryptionKey, m.lo, tctx)
ht := newHTTPTool(ct, m.encryptionKey, m.lo, m.toolClient, tctx)
registry[ht.Name()] = ht
defs = append(defs, toolDef(ht))
}
+42 -20
View File
@@ -6,12 +6,14 @@ import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/ssrf"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/coreos/go-oidc/v3/oidc"
@@ -57,23 +59,26 @@ const defaultSessionLifetime = 9 * time.Hour
// Auth is the auth service it manages OIDC authentication and sessions
type Auth struct {
mu sync.RWMutex
cfg Config
i18n *i18n.I18n
oauthCfgs map[int]oauth2.Config
verifiers map[int]*oidc.IDTokenVerifier
sess *simplesessions.Manager
logger *logf.Logger
rd *redis.Client
mu sync.RWMutex
cfg Config
i18n *i18n.I18n
oauthCfgs map[int]oauth2.Config
verifiers map[int]*oidc.IDTokenVerifier
sess *simplesessions.Manager
logger *logf.Logger
rd *redis.Client
oidcClient *http.Client
}
// New creates an Auth service with configured OIDC providers
func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
// New creates an Auth service with configured OIDC providers.
func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger, dialControl ssrf.Control) (*Auth, error) {
oauthCfgs := make(map[int]oauth2.Config)
verifiers := make(map[int]*oidc.IDTokenVerifier)
oidcClient := newOIDCClient(dialControl)
for _, provider := range cfg.Providers {
oidcProv, err := oidc.NewProvider(context.Background(), provider.ProviderURL)
oidcProv, err := oidc.NewProvider(oidc.ClientContext(context.Background(), oidcClient), provider.ProviderURL)
if err != nil {
logger.Error("error initializing oidc provider", "error", err, "provider", provider.Provider)
continue
@@ -116,19 +121,36 @@ func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger) (*A
sess.SetCookieHooks(simpleSessGetCookieCB, simpleSessSetCookieCB)
return &Auth{
cfg: cfg,
i18n: i18n,
oauthCfgs: oauthCfgs,
verifiers: verifiers,
sess: sess,
logger: logger,
rd: rd,
cfg: cfg,
i18n: i18n,
oauthCfgs: oauthCfgs,
verifiers: verifiers,
sess: sess,
logger: logger,
rd: rd,
oidcClient: oidcClient,
}, nil
}
// newOIDCClient builds the HTTP client used for OIDC discovery.
func newOIDCClient(dialControl ssrf.Control) *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
Control: dialControl,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
},
}
}
// TestProvider tests the OIDC provider url by doing a discovery on it.
func (a *Auth) TestProvider(url string) error {
_, err := oidc.NewProvider(context.Background(), url)
_, err := oidc.NewProvider(oidc.ClientContext(context.Background(), a.oidcClient), url)
if err != nil {
a.logger.Error("error testing oidc provider", "provider_url", url, "error", err)
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
@@ -145,7 +167,7 @@ func (a *Auth) Reload(cfg Config) error {
verifiers := make(map[int]*oidc.IDTokenVerifier)
for _, provider := range cfg.Providers {
oidcProv, err := oidc.NewProvider(context.Background(), provider.ProviderURL)
oidcProv, err := oidc.NewProvider(oidc.ClientContext(context.Background(), a.oidcClient), provider.ProviderURL)
if err != nil {
a.logger.Error("error initializing oidc provider", "provider", provider.Provider, "provider_url", provider.ProviderURL, "error", err)
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
+36
View File
@@ -0,0 +1,36 @@
// Package ssrf builds the shared outbound-request guard used across every
// admin-configured outbound URL (webhooks, OIDC discovery, AI provider and
// custom tool calls). It is off by default and meant to be enabled on
// multi-tenant/hosted deployments where those URLs come from untrusted tenants.
package ssrf
import (
"net/netip"
"strings"
"syscall"
"github.com/abhinavxd/ssrfguard"
"github.com/zerodha/logf"
)
// Control matches net.Dialer.Control. A nil value means no guarding.
type Control = func(network, address string, c syscall.RawConn) error
// NewControl returns a net.Dialer.Control that blocks connections to private and
// reserved IP ranges, or nil when disabled. allowedCIDRs carve out exceptions
// (e.g. an operator's own internal host) that stay reachable while enabled.
func NewControl(enabled bool, allowedCIDRs []string, lo *logf.Logger) Control {
if !enabled {
return nil
}
var allowed []netip.Prefix
for _, c := range allowedCIDRs {
prefix, err := netip.ParsePrefix(strings.TrimSpace(c))
if err != nil {
lo.Warn("ignoring invalid ssrf `allowed_cidrs` entry", "entry", c, "error", err)
continue
}
allowed = append(allowed, prefix)
}
return ssrfguard.New(allowed...).Control
}
+3 -22
View File
@@ -14,7 +14,6 @@ import (
"io"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
@@ -22,10 +21,10 @@ import (
"github.com/abhinavxd/libredesk/internal/crypto"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/ssrf"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/version"
"github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/abhinavxd/ssrfguard"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/lib/pq"
@@ -61,7 +60,7 @@ type Opts struct {
QueueSize int
Timeout time.Duration
EncryptionKey string
AllowedHosts []string // CIDR prefixes allowed to bypass SSRF protection
DialControl ssrf.Control
}
// DeliveryTask represents a webhook delivery task
@@ -91,10 +90,6 @@ func New(opts Opts) (*Manager, error) {
return nil, err
}
// Parse allowed host CIDRs for SSRF exceptions.
allowed := parseAllowedHosts(opts.AllowedHosts, opts.Lo)
guard := ssrfguard.New(allowed...)
return &Manager{
q: q,
lo: opts.Lo,
@@ -107,7 +102,7 @@ func New(opts Opts) (*Manager, error) {
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
Control: guard.Control,
Control: opts.DialControl,
}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 3 * time.Second,
@@ -414,17 +409,3 @@ func (m *Manager) getWebhooksByEvent(event string) ([]models.Webhook, error) {
return webhooks, nil
}
// parseAllowedHosts parses CIDR strings into netip.Prefix slices.
func parseAllowedHosts(hosts []string, lo *logf.Logger) []netip.Prefix {
var prefixes []netip.Prefix
for _, h := range hosts {
prefix, err := netip.ParsePrefix(h)
if err != nil {
lo.Warn("ignoring invalid webhook `allowed_hosts` entry", "entry", h, "error", err)
continue
}
prefixes = append(prefixes, prefix)
}
return prefixes
}