Files
43837afca3 fix(security): consolidate & enhance batched security fixes (#1155, #967, #972, #974, #989, #973) (#1185)
* fix(sandbox): avoid shell in FsBridge writes

Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form,
piping content via stdin. Prevents command injection through filenames
containing shell metacharacters inside the sandbox container.

Co-authored-by: evgyur <evgyur@gmail.com>

* fix(security): fail-closed on pairing DB errors across channels

On IsPaired lookup error, deny instead of granting access. Covers the shared
CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and
the four inline Telegram pairing checks.

Co-authored-by: Srini <srinis.k@gmail.com>

* fix(security): harden provider URL validation against SSRF

Enforce scheme check for all provider types; restrict local types (ollama,
claude_cli, acp) to an explicit localhost allowlist instead of skipping checks;
resolve remote hostnames and reject any IP in a private/reserved range via the
shared security.IsBlocked CIDR list (covers loopback, link-local, metadata,
multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the
local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS.

Exports security.IsBlocked as the single source of truth for blocked ranges.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* feat(pipeline): add fail-closed tool call authorization gate

Gate tool execution against the server-side AllowedTools allowlist built from the
RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the
allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy
MCP activation, and expand IsDenied to cover aliased tool names.

Co-authored-by: Huy Doan <tui@pm.me>

* fix(security): expand file-serve deny-list defense-in-depth

Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt)
and an explicit fail-closed log when no file-serving boundary is configured.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* fix(providers): allow claude cli executable paths

Refs: #1185

---------

Co-authored-by: evgyur <evgyur@gmail.com>
Co-authored-by: Srini <srinis.k@gmail.com>
Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>
Co-authored-by: Huy Doan <tui@pm.me>
2026-06-05 00:48:38 +07:00

226 lines
7.4 KiB
Go

// Package security provides SSRF-safe HTTP utilities for outbound webhook calls.
// All production webhook HTTP clients MUST use NewSafeClient to prevent
// admin-configured hooks from probing internal infrastructure.
package security
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"sync/atomic"
"time"
)
// pinnedIPKey is a context key used to pass the pre-resolved IP from
// Validate into the custom DialContext, preventing DNS rebinding attacks.
type pinnedIPKey struct{}
// allowLoopbackForTest is a test-only bypass. Production code MUST never set
// this flag. Exposed via SetAllowLoopbackForTest exclusively for *_test.go.
// atomic.Bool keeps read/write safe when tests spawn goroutines that trigger
// outbound calls concurrently with the flag flip.
var allowLoopbackForTest atomic.Bool
// SetAllowLoopbackForTest enables or disables the loopback/private-CIDR bypass
// for tests. Call with true before tests that use httptest.NewServer, and
// always defer a call with false to restore the default.
//
// This function MUST only be called from test code. Production paths never
// set this flag — the zero value (false) is the safe default.
func SetAllowLoopbackForTest(allow bool) {
allowLoopbackForTest.Store(allow)
}
// blockedCIDRs lists all CIDRs that must never be dialed.
var blockedCIDRs []*net.IPNet
func init() {
cidrs := []string{
// Loopback
"127.0.0.0/8",
"::1/128",
// Link-local (includes cloud-metadata 169.254.169.254)
"169.254.0.0/16",
"fe80::/10",
// Private (RFC 1918 + RFC 4193)
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
// Multicast
"224.0.0.0/4",
"ff00::/8",
// Unspecified
"0.0.0.0/32",
"::/128",
}
for _, cidr := range cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic(fmt.Sprintf("security: bad CIDR %q: %v", cidr, err))
}
blockedCIDRs = append(blockedCIDRs, ipNet)
}
}
// isBlocked returns true if ip falls within any blocked CIDR.
func isBlocked(ip net.IP) bool {
for _, cidr := range blockedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
}
// IsBlocked reports whether ip falls within any blocked CIDR (loopback,
// link-local including cloud-metadata 169.254.169.254, RFC 1918 private,
// multicast, and unspecified 0.0.0.0/:: ranges).
//
// Use this in provider-URL validation and dial-time guards to avoid
// duplicating the CIDR list across packages.
func IsBlocked(ip net.IP) bool {
return isBlocked(ip)
}
// redactURL strips query string and userinfo for safe logging.
func redactURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return "[unparseable]"
}
u.RawQuery = ""
u.Fragment = ""
u.User = nil
return u.String()
}
// Validate parses rawURL, resolves the host once, and rejects loopback,
// link-local, private, multicast, unspecified, and cloud-metadata destinations.
// Returns the parsed URL and the pinned resolved IP that the caller should dial
// directly. Only http and https schemes are accepted.
//
// In test code, call SetAllowLoopbackForTest(true) before invoking this
// function to permit httptest.NewServer addresses (127.0.0.1).
func Validate(rawURL string) (*url.URL, net.IP, error) {
return validate(rawURL, allowLoopbackForTest.Load())
}
// validate is the internal implementation; allowLoopback is set only in tests.
func validate(rawURL string, allowLoopback bool) (*url.URL, net.IP, error) {
redacted := redactURL(rawURL)
u, err := url.Parse(rawURL)
if err != nil {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "url_parse_error")
return nil, nil, fmt.Errorf("ssrf: parse url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "non_http_scheme", "scheme", u.Scheme)
return nil, nil, fmt.Errorf("ssrf: scheme %q not allowed (only http/https)", u.Scheme)
}
host := u.Hostname()
if host == "" {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "empty_host")
return nil, nil, errors.New("ssrf: empty host")
}
// If the host is already a literal IP, validate it directly.
if ip := net.ParseIP(host); ip != nil {
if !allowLoopback && isBlocked(ip) {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "blocked_ip", "ip", ip.String())
return nil, nil, fmt.Errorf("ssrf: IP %s is in a blocked range", ip)
}
return u, ip, nil
}
// DNS resolution — pin the first returned IP.
addrs, err := net.LookupHost(host)
if err != nil {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "dns_resolve_failed", "host", host)
return nil, nil, fmt.Errorf("ssrf: resolve %q: %w", host, err)
}
if len(addrs) == 0 {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "no_ips_resolved", "host", host)
return nil, nil, fmt.Errorf("ssrf: %q resolved to no addresses", host)
}
ip := net.ParseIP(addrs[0])
if ip == nil {
return nil, nil, fmt.Errorf("ssrf: resolved address %q is not a valid IP", addrs[0])
}
if !allowLoopback && isBlocked(ip) {
slog.Warn("security.hook.ssrf_block", "url", redacted, "reason", "blocked_resolved_ip", "host", host, "ip", ip.String())
return nil, nil, fmt.Errorf("ssrf: %q resolved to blocked IP %s", host, ip)
}
return u, ip, nil
}
// WithPinnedIP stores the pinned IP in ctx so the safe DialContext can read it.
func WithPinnedIP(ctx context.Context, ip net.IP) context.Context {
return context.WithValue(ctx, pinnedIPKey{}, ip)
}
// pinnedIPFrom retrieves the pinned IP from ctx. Returns nil if not set.
func pinnedIPFrom(ctx context.Context) net.IP {
ip, _ := ctx.Value(pinnedIPKey{}).(net.IP)
return ip
}
// NewSafeClient returns an *http.Client whose Transport.DialContext pins the
// destination to the IP stored in the request context via WithPinnedIP (so DNS
// rebinding cannot swap a public IP for a private one between Validate and Dial),
// refuses redirects, and applies the supplied per-request timeout.
// The returned client is safe to share across goroutines.
//
// Caller workflow:
// 1. Call Validate(url) → get pinnedIP
// 2. ctx = WithPinnedIP(ctx, pinnedIP)
// 3. http.NewRequestWithContext(ctx, ...)
// 4. client.Do(req)
func NewSafeClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: timeout}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
pinnedIP := pinnedIPFrom(ctx)
if pinnedIP == nil {
// No pinned IP in context — reject for safety.
return nil, errors.New("ssrf: no pinned IP in context; call security.WithPinnedIP before dialing")
}
// Defense-in-depth: re-check pinned IP against block list.
// allowLoopbackForTest bypasses this check in test code only.
if !allowLoopbackForTest.Load() && isBlocked(pinnedIP) {
return nil, fmt.Errorf("ssrf: pinned IP %s is in a blocked range", pinnedIP)
}
// Replace the host portion of addr with the pinned IP,
// preserving the port from addr.
_, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("ssrf: split host/port from %q: %w", addr, err)
}
pinnedAddr := net.JoinHostPort(pinnedIP.String(), port)
return dialer.DialContext(ctx, network, pinnedAddr)
},
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Never follow redirects — return the redirect response directly.
return http.ErrUseLastResponse
},
}
}