Files
MindSift/internal/api/netguard.go
T
sandrodachandClaude Opus 5.5 62463bc6fd fix(security): SSRF protection for alarm webhooks (FLAW-05)
Discord and custom webhook URLs are checked when saved and before every
call: https only, Discord URLs must point to discord.com. A guarded HTTP
client resolves hosts itself and connects only to allowed IPs; localhost,
link-local and cloud metadata are always blocked, LAN ranges (incl.
Tailscale CGNAT) and plain http only with the new admin switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:37:39 +01:00

135 lines
3.8 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// Outgoing alarm requests (Discord, custom webhooks) go to user-supplied URLs. Without checks a user
// could make the server call internal services (SSRF): localhost, the Docker network or cloud metadata.
var errBlockedTarget = errors.New("Ziel nicht erlaubt: interne oder reservierte Adresse")
func mustCIDRs(list ...string) []*net.IPNet {
out := make([]*net.IPNet, 0, len(list))
for _, c := range list {
_, n, err := net.ParseCIDR(c)
if err != nil {
panic(err)
}
out = append(out, n)
}
return out
}
var (
// Never reachable: this machine, link-local incl. cloud metadata (169.254.169.254), "this network"
alwaysBlocked = mustCIDRs("127.0.0.0/8", "::1/128", "169.254.0.0/16", "fe80::/10", "0.0.0.0/8", "::/128")
// Home/LAN ranges (incl. CGNAT as used by Tailscale), only with the admin switch
// "Ziele im lokalen Netz erlauben"
privateRanges = mustCIDRs("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
)
func targetIPAllowed(ip net.IP, allowPrivate bool) bool {
if ip == nil || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, n := range alwaysBlocked {
if n.Contains(ip) {
return false
}
}
if !allowPrivate {
for _, n := range privateRanges {
if n.Contains(ip) {
return false
}
}
}
return true
}
// validateWebhookURL checks a URL before it is stored or called. HTTPS is required unless the admin
// allows local targets (LAN services often speak plain HTTP). Discord URLs must point to Discord.
func validateWebhookURL(raw string, discord, allowPrivate bool) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return errors.New("Ungültige URL")
}
switch u.Scheme {
case "https":
case "http":
if !allowPrivate {
return errors.New("Nur https-URLs sind erlaubt (http nur mit freigegebenen Zielen im lokalen Netz)")
}
default:
return errors.New("Nur https-URLs sind erlaubt")
}
if discord {
host := strings.ToLower(u.Hostname())
if u.Scheme != "https" || (host != "discord.com" && host != "discordapp.com") || !strings.HasPrefix(u.Path, "/api/webhooks/") {
return errors.New("Discord-Webhooks müssen mit https://discord.com/api/webhooks/ beginnen")
}
}
if ip := net.ParseIP(u.Hostname()); ip != nil && !targetIPAllowed(ip, allowPrivate) {
return errBlockedTarget
}
return nil
}
// guardedClient resolves the host itself and only connects to allowed addresses. Checking at dial time
// (not just the URL) also covers DNS tricks and redirects; proxies from the environment are not used.
func guardedClient(allowPrivate bool) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if targetIPAllowed(ip.IP, allowPrivate) {
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
}
}
return nil, fmt.Errorf("%w (%s)", errBlockedTarget, host)
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 10,
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("zu viele Weiterleitungen")
}
return nil
},
}
}
func (s *Server) allowPrivateWebhooks() bool {
sys, _ := s.store.GetSystemSettings()
return sys != nil && sys.AllowPrivateWebhooks
}