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>
This commit is contained in:
1 parent
4cdbcbd6b5
commit
62463bc6fd
8 files changed
+288
-16
No files matched your search
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTargetIPAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
ip string
|
||||
allowPrivate bool
|
||||
want bool
|
||||
}{
|
||||
{"127.0.0.1", true, false},
|
||||
{"::1", true, false},
|
||||
{"169.254.169.254", true, false}, // cloud metadata
|
||||
{"0.0.0.0", true, false},
|
||||
{"192.168.1.20", false, false},
|
||||
{"192.168.1.20", true, true},
|
||||
{"10.0.0.5", false, false},
|
||||
{"100.101.102.103", true, true}, // Tailscale
|
||||
{"fd00::1", false, false},
|
||||
{"93.184.216.34", false, true},
|
||||
{"::ffff:127.0.0.1", true, false}, // IPv4-mapped loopback
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := targetIPAllowed(net.ParseIP(c.ip), c.allowPrivate); got != c.want {
|
||||
t.Errorf("targetIPAllowed(%s, private=%v) = %v, want %v", c.ip, c.allowPrivate, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWebhookURL(t *testing.T) {
|
||||
ok := []struct {
|
||||
url string
|
||||
discord, private bool
|
||||
}{
|
||||
{"https://discord.com/api/webhooks/1/abc", true, false},
|
||||
{"https://hooks.example.org/x", false, false},
|
||||
{"http://192.168.1.20:8123/api/webhook/x", false, true},
|
||||
{"", false, false},
|
||||
}
|
||||
for _, c := range ok {
|
||||
if err := validateWebhookURL(c.url, c.discord, c.private); err != nil {
|
||||
t.Errorf("%q should be allowed: %v", c.url, err)
|
||||
}
|
||||
}
|
||||
bad := []struct {
|
||||
url string
|
||||
discord, private bool
|
||||
}{
|
||||
{"http://hooks.example.org/x", false, false},
|
||||
{"https://evil.example.org/api/webhooks/1", true, false},
|
||||
{"https://127.0.0.1/x", false, true},
|
||||
{"https://169.254.169.254/latest/meta-data", false, true},
|
||||
{"https://192.168.1.20/x", false, false},
|
||||
{"file:///etc/passwd", false, true},
|
||||
{"gopher://x", false, false},
|
||||
}
|
||||
for _, c := range bad {
|
||||
if err := validateWebhookURL(c.url, c.discord, c.private); err == nil {
|
||||
t.Errorf("%q must be rejected", c.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A hostname that resolves to localhost (like a test server or a DNS rebinding trick) is refused at
|
||||
// connection time even though the URL itself looks harmless.
|
||||
func TestGuardedClientRefusesLoopbackAtDial(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||||
defer ts.Close()
|
||||
url := strings.Replace(ts.URL, "127.0.0.1", "localhost", 1)
|
||||
if _, err := guardedClient(true).Get(url); err == nil || !strings.Contains(err.Error(), "nicht erlaubt") {
|
||||
t.Fatalf("loopback must be refused at dial time, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user