From 9566dcbf4d776c8472f89ca8abb8bfa050fb7096 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 13 Jul 2026 03:13:56 +0530 Subject: [PATCH] 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. --- SECURITY.md | 2 +- cmd/init.go | 27 +++++++++++++--- cmd/main.go | 7 +++-- config.sample.toml | 13 ++++++-- internal/ai/agent.go | 2 +- internal/ai/ai.go | 23 ++++++++++++-- internal/ai/openai.go | 20 +++++++++--- internal/ai/tools.go | 9 ++---- internal/auth/auth.go | 62 +++++++++++++++++++++++++------------ internal/ssrf/ssrf.go | 36 +++++++++++++++++++++ internal/webhook/webhook.go | 25 ++------------- 11 files changed, 160 insertions(+), 66 deletions(-) create mode 100644 internal/ssrf/ssrf.go diff --git a/SECURITY.md b/SECURITY.md index 8d13f3db..4ee9e6ed 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,6 @@ The permission system is a policy layer over already-trusted users, not a bounda ## Out of scope - An admin exercising a documented admin capability (configuring webhooks, OIDC providers, automations, templates, inboxes, etc.). It is up to the operator to grant these capabilities only to trusted users. Defects in the admin code paths (sqli, RCE, auth bypass etc.) remain in scope. -- SSRF via admin-configured outbound URLs (webhooks, OIDC provider discovery, etc.). +- SSRF via admin-configured outbound URLs (webhooks, OIDC provider discovery, AI provider and custom tool calls, etc.). In single-tenant self-hosted deployments the admin already controls the host, so this is not a privilege boundary. Deployments where these URLs come from untrusted parties (e.g. multi-tenant/hosted) can enable the `[ssrf]` guard in the config to block requests to private/reserved IP ranges. Anything else is in scope. diff --git a/cmd/init.go b/cmd/init.go index 62efed19..61f878f0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -46,6 +46,7 @@ import ( "github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/sla" + "github.com/abhinavxd/libredesk/internal/ssrf" "github.com/abhinavxd/libredesk/internal/tag" "github.com/abhinavxd/libredesk/internal/team" tmpl "github.com/abhinavxd/libredesk/internal/template" @@ -787,8 +788,23 @@ func initAuthz(i18n *i18n.I18n) *authz.Enforcer { return enforcer } +// initSSRFControl builds the shared outbound-request guard from config, still honoring the deprecated `webhook.allowed_hosts` key. +func initSSRFControl() ssrf.Control { + lo := initLogger("ssrf") + enabled := ko.Bool("ssrf.enabled") + cidrs := ko.Strings("ssrf.allowed_cidrs") + + if legacy := ko.Strings("webhook.allowed_hosts"); len(legacy) > 0 { + lo.Warn("`webhook.allowed_hosts` is deprecated; use the [ssrf] config block instead") + enabled = true + cidrs = append(cidrs, legacy...) + } + + return ssrf.NewControl(enabled, cidrs, lo) +} + // initAuth initializes the authentication manager. -func initAuth(o *oidc.Manager, rd *redis.Client, i18n *i18n.I18n) *auth_.Auth { +func initAuth(o *oidc.Manager, rd *redis.Client, i18n *i18n.I18n, dialControl ssrf.Control) *auth_.Auth { lo := initLogger("auth") providers, err := buildProviders(o) @@ -798,7 +814,7 @@ func initAuth(o *oidc.Manager, rd *redis.Client, i18n *i18n.I18n) *auth_.Auth { secure := !ko.Bool("app.server.disable_secure_cookies") sessionLifetime := ko.Duration("app.server.session_lifetime") - auth, err := auth_.New(auth_.Config{Providers: providers, SecureCookies: secure, SessionLifetime: sessionLifetime}, i18n, rd, lo) + auth, err := auth_.New(auth_.Config{Providers: providers, SecureCookies: secure, SessionLifetime: sessionLifetime}, i18n, rd, lo, dialControl) if err != nil { log.Fatalf("error initializing auth: %v", err) } @@ -960,13 +976,14 @@ func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager { } // initAI inits AI manager. -func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager { +func initAI(db *sqlx.DB, i18n *i18n.I18n, dialControl ssrf.Control) *ai.Manager { lo := initLogger("ai") m, err := ai.New(ai.Opts{ DB: db, Lo: lo, I18n: i18n, EncryptionKey: ko.MustString("app.encryption_key"), + DialControl: dialControl, }) if err != nil { log.Fatalf("error initializing AI manager: %v", err) @@ -1060,7 +1077,7 @@ func initContextLink(db *sqlx.DB, i18n *i18n.I18n) *contextlink.Manager { } // initWebhook inits webhook manager. -func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager { +func initWebhook(db *sqlx.DB, i18n *i18n.I18n, dialControl ssrf.Control) *webhook.Manager { var lo = initLogger("webhook") m, err := webhook.New(webhook.Opts{ DB: db, @@ -1070,7 +1087,7 @@ func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager { QueueSize: ko.MustInt("webhook.queue_size"), Timeout: ko.MustDuration("webhook.timeout"), EncryptionKey: ko.MustString("app.encryption_key"), - AllowedHosts: ko.Strings("webhook.allowed_hosts"), + DialControl: dialControl, }) if err != nil { log.Fatalf("error initializing webhook manager: %v", err) diff --git a/cmd/main.go b/cmd/main.go index 9a5a129c..0e94c858 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -215,20 +215,21 @@ func main() { oidc = initOIDC(db, settings, i18n) status = initStatus(db, i18n) priority = initPriority(db, i18n) - auth = initAuth(oidc, rdb, i18n) + ssrfControl = initSSRFControl() + auth = initAuth(oidc, rdb, i18n, ssrfControl) template = initTemplate(db, fs, constants, i18n) media = initMedia(db, i18n, settings) inbox = initInbox(db, i18n) team = initTeam(db, i18n) businessHours = initBusinessHours(db, i18n) - webhook = initWebhook(db, i18n) + webhook = initWebhook(db, i18n, ssrfControl) user = initUser(i18n, db) wsHub = initWS(user) notifier = initNotifier() userNotification = initUserNotification(db, i18n) notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled")) automation = initAutomationEngine(db, i18n) - ai = initAI(db, i18n) + ai = initAI(db, i18n, ssrfControl) sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher) conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher) aiAgent = initAIAgent(db, i18n, ai, conversation, media, settings) diff --git a/config.sample.toml b/config.sample.toml index b218ffa0..b7b329d7 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -133,8 +133,17 @@ workers = 5 queue_size = 10000 # HTTP timeout for webhook requests timeout = "15s" -# CIDR ranges allowed to bypass SSRF protection (e.g. ["10.0.0.0/8"]) -allowed_hosts = [] + +# SSRF guard for admin-configured outbound URLs (webhooks, OIDC discovery, AI provider and tool calls). +[ssrf] +# Block outbound requests to private/loopback/link-local/cloud-metadata IPs. +# Leave false for self-hosted single-tenant deployments, where internal webhooks, +# OIDC providers and model endpoints on private IPs are legitimate. +# Set true for multi-tenant/hosted deployments where those URLs come from untrusted tenants. +enabled = false +# CIDR ranges allowed to bypass the guard when enabled, for reaching known internal hosts. +# Examples: ["10.0.0.0/8", "192.168.1.10/32", "172.16.0.0/12", "fd00::/8"] +allowed_cidrs = [] [conversation] # How often to check for conversations to unsnooze diff --git a/internal/ai/agent.go b/internal/ai/agent.go index b30c76ce..18cbce3e 100644 --- a/internal/ai/agent.go +++ b/internal/ai/agent.go @@ -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 } diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 63829519..574f35c8 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -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 { diff --git a/internal/ai/openai.go b/internal/ai/openai.go index f85e68e8..99a84332 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -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, + }, + }, } } diff --git a/internal/ai/tools.go b/internal/ai/tools.go index eec75522..6df5d8a7 100644 --- a/internal/ai/tools.go +++ b/internal/ai/tools.go @@ -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)) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 31e4735f..c42f4fd5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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) diff --git a/internal/ssrf/ssrf.go b/internal/ssrf/ssrf.go new file mode 100644 index 00000000..95bdf76e --- /dev/null +++ b/internal/ssrf/ssrf.go @@ -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 +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 005ffe87..336b3aed 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -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 -}