Pulse/internal/notifications/notifications_security_http_test.go
2026-04-01 15:50:30 +01:00

315 lines
8.6 KiB
Go

package notifications
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
)
func TestSendAppriseViaHTTPEmptyServerURL(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
err := nm.sendAppriseViaHTTP(AppriseConfig{TimeoutSeconds: 1}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "server URL is not configured") {
t.Fatalf("expected missing server URL error, got %v", err)
}
}
func TestSendAppriseViaHTTPInvalidScheme(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
err := nm.sendAppriseViaHTTP(AppriseConfig{ServerURL: "ftp://example.com", TimeoutSeconds: 1}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "http or https") {
t.Fatalf("expected scheme validation error, got %v", err)
}
}
func TestSendAppriseViaHTTPRejectsUserinfoServerURL(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
called := make(chan struct{}, 1)
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case called <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
serverURL.User = url.UserPassword("user", "pass")
err = nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: serverURL.String(),
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "userinfo") {
t.Fatalf("expected userinfo validation error, got %v", err)
}
select {
case <-called:
t.Fatal("expected userinfo-bearing Apprise URL to be rejected before outbound delivery")
default:
}
}
func TestSendAppriseViaHTTPRejectsQueryOrFragmentInServerURL(t *testing.T) {
tests := []struct {
name string
serverURL func(string) string
wantErr string
}{
{
name: "query",
serverURL: func(base string) string {
return base + "/apprise/api?token=secret"
},
wantErr: "base URL must not include query or fragment",
},
{
name: "fragment",
serverURL: func(base string) string {
return base + "/apprise/api#notify"
},
wantErr: "base URL must not include query or fragment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
called := make(chan struct{}, 1)
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case called <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: tt.serverURL(server.URL),
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected %q validation error, got %v", tt.wantErr, err)
}
select {
case <-called:
t.Fatal("expected query/fragment-bearing Apprise base URL to be rejected before outbound delivery")
default:
}
})
}
}
func TestSendTestWebhookRejectsUserinfoURLBeforeOutboundDelivery(t *testing.T) {
nm := NewNotificationManager("https://pulse.local")
defer nm.Stop()
called := make(chan struct{}, 1)
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case called <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
serverURL.User = url.UserPassword("user", "pass")
err = nm.SendTestWebhook(WebhookConfig{
Name: "Userinfo Webhook",
URL: serverURL.String(),
Enabled: true,
})
if err == nil || !strings.Contains(err.Error(), "userinfo") {
t.Fatalf("expected userinfo validation error, got %v", err)
}
select {
case <-called:
t.Fatal("expected userinfo-bearing webhook URL to be rejected before outbound delivery")
default:
}
}
func TestSendAppriseViaHTTPBlocksRedirectToLinkLocal(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data", http.StatusFound)
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil {
t.Fatalf("expected redirect validation error")
}
if !strings.Contains(err.Error(), "link-local addresses are not allowed") {
t.Fatalf("expected link-local redirect to be blocked, got %v", err)
}
}
func TestSendAppriseViaHTTPSkipTLSVerifyAndDefaultHeader(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := newIPv4TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-API-KEY"); got != "secret" {
t.Fatalf("expected default API key header, got %q", got)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
SkipTLSVerify: true,
APIKey: "secret",
TimeoutSeconds: 2,
}, "title", "body", "info")
if err != nil {
t.Fatalf("expected HTTPS request to succeed, got %v", err)
}
}
func TestSendAppriseViaHTTPIncludesTargetsAndCustomHeader(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
type payload struct {
Body string `json:"body"`
Title string `json:"title"`
Type string `json:"type"`
URLs []string `json:"urls"`
}
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Test-Key"); got != "secret" {
t.Fatalf("expected custom API key header, got %q", got)
}
if got := r.URL.EscapedPath(); got != "/apprise/api/notify/config%20key" {
t.Fatalf("expected base path to be preserved, got %q", got)
}
var p payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Fatalf("decode payload: %v", err)
}
if len(p.URLs) != 1 || p.URLs[0] != "discord://token" {
t.Fatalf("expected urls in payload, got %#v", p.URLs)
}
if p.Type != "warning" {
t.Fatalf("expected notify type warning, got %q", p.Type)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL + "/apprise/api/",
ConfigKey: "config key",
APIKey: "secret",
APIKeyHeader: "X-Test-Key",
Targets: []string{"discord://token"},
TimeoutSeconds: 2,
}, "title", "body", "warning")
if err != nil {
t.Fatalf("expected HTTP request to succeed, got %v", err)
}
}
func TestSendAppriseViaHTTPStatusErrorWithBody(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("boom"))
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("expected status error with body, got %v", err)
}
}
func TestSendAppriseViaHTTPStatusErrorWithoutBody(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
t.Fatalf("allowlist: %v", err)
}
err := nm.sendAppriseViaHTTP(AppriseConfig{
ServerURL: server.URL,
TimeoutSeconds: 2,
}, "title", "body", "info")
if err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("expected status error, got %v", err)
}
}