mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-22 20:45:23 +00:00
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:
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user