500 lines
16 KiB
Go
500 lines
16 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
neturl "net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHTTPWrapperRejectsOversizedRequestBody(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
payload := make([]byte, MaxNotifyRequestBodyBytes+1)
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: "http://example.com/hook",
|
|
Body: payload,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "request payload exceeds") {
|
|
t.Fatalf("expected oversized request body error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperRejectsTokenizedQueryURL(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: "http://example.com/hook?token=secret",
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
|
|
t.Fatalf("expected query token rejection, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperRejectsQueryAuthCaseVariants(t *testing.T) {
|
|
testCases := []string{
|
|
"http://example.com/hook?Token=secret",
|
|
"http://example.com/hook?AUTH=secret",
|
|
"http://example.com/hook?apiKey=secret",
|
|
}
|
|
|
|
for _, testURL := range testCases {
|
|
t.Run(testURL, func(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: testURL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
|
|
t.Fatalf("expected query auth rejection for %q, got: %v", testURL, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperSendRejectsRedirectTargetWithDisallowedScheme(t *testing.T) {
|
|
var attempts int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&attempts, 1)
|
|
http.Redirect(w, r, "ftp://example.com/redirected", http.StatusFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
wrapper.maxRedirects = 3
|
|
wrapper.retryPolicy.MaxAttempts = 1
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: server.URL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
|
|
t.Fatalf("expected outbound failure due to redirect target validation, got: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&attempts); got != 1 {
|
|
t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperSendRejectsRedirectTargetWithMixedCaseQueryAuth(t *testing.T) {
|
|
var attempts int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&attempts, 1)
|
|
http.Redirect(w, r, "https://example.com/redirected?Token=secret", http.StatusFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
wrapper.maxRedirects = 3
|
|
wrapper.retryPolicy.MaxAttempts = 1
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: server.URL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "outbound request failed") {
|
|
t.Fatalf("expected outbound failure due to redirect query auth validation, got: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&attempts); got != 1 {
|
|
t.Fatalf("expected only initial request due to blocked redirect, got %d attempts", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperRetriesOn429ThenSucceeds(t *testing.T) {
|
|
var calls int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
current := atomic.AddInt32(&calls, 1)
|
|
if current == 1 {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
wrapper.sleep = func(time.Duration) {}
|
|
wrapper.jitterNanos = func(int64) int64 { return 0 }
|
|
|
|
result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: server.URL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success after retry, got error: %v", err)
|
|
}
|
|
if result.Attempts != 2 {
|
|
t.Fatalf("expected 2 attempts, got %d", result.Attempts)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperSendSuccessWithValidatedDestination(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
|
t.Fatalf("expected default content-type, got %q", got)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
wrapper.retryPolicy.MaxAttempts = 1
|
|
wrapper.httpClientFactory = func(bool, int) *http.Client {
|
|
return server.Client()
|
|
}
|
|
|
|
result, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: server.URL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected successful send, got error: %v", err)
|
|
}
|
|
if result.Attempts != 1 {
|
|
t.Fatalf("expected 1 attempt, got %d", result.Attempts)
|
|
}
|
|
if result.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d", http.StatusOK, result.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperSendRejectsUserInfoInDestinationURL(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: "https://user:pass@example.com/hook",
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected destination validation failure, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperSendRejectsFragmentInDestinationURL(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: "https://example.com/hook#fragment",
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected destination validation failure, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperDoesNotRetryOn400(t *testing.T) {
|
|
var calls int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}))
|
|
defer server.Close()
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
wrapper.sleep = func(time.Duration) {}
|
|
wrapper.jitterNanos = func(int64) int64 { return 0 }
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: server.URL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "status 400") {
|
|
t.Fatalf("expected non-retryable 400 error, got: %v", err)
|
|
}
|
|
if atomic.LoadInt32(&calls) != 1 {
|
|
t.Fatalf("expected exactly one request attempt, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperResponseBodyCap(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, strings.Repeat("x", MaxNotifyResponseBodyBytes+8))
|
|
}))
|
|
defer server.Close()
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
_, err := wrapper.Send(context.Background(), HTTPWrapperRequest{
|
|
URL: server.URL,
|
|
Body: []byte(`{"message":"hello"}`),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "response payload exceeds") {
|
|
t.Fatalf("expected capped response body error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeOutboundHeadersAllowlist(t *testing.T) {
|
|
headers := sanitizeOutboundHeaders(map[string]string{
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Charon",
|
|
"X-Request-ID": "abc",
|
|
"X-Gotify-Key": "secret",
|
|
"Authorization": "Bearer token",
|
|
"Cookie": "sid=1",
|
|
})
|
|
|
|
if len(headers) != 4 {
|
|
t.Fatalf("expected 4 allowed headers, got %d", len(headers))
|
|
}
|
|
if _, ok := headers["Authorization"]; ok {
|
|
t.Fatalf("authorization header must be stripped")
|
|
}
|
|
if _, ok := headers["Cookie"]; ok {
|
|
t.Fatalf("cookie header must be stripped")
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLRejectsNilRequest(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
err := wrapper.guardOutboundRequestURL(nil)
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected validation failure for nil request, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLRejectsQueryAuth(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "token=secret"}}
|
|
err := wrapper.guardOutboundRequestURL(httpReq)
|
|
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
|
|
t.Fatalf("expected query auth rejection, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLRejectsMixedCaseQueryAuth(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook", RawQuery: "apiKey=secret"}}
|
|
err := wrapper.guardOutboundRequestURL(httpReq)
|
|
if err == nil || !strings.Contains(err.Error(), "query authentication is not allowed") {
|
|
t.Fatalf("expected query auth rejection, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperApplyRedirectGuardPreservesOriginalBehavior(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
baseErr := fmt.Errorf("base redirect policy")
|
|
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return baseErr
|
|
}}
|
|
|
|
wrapper.applyRedirectGuard(client)
|
|
err := client.CheckRedirect(&http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com"}}, nil)
|
|
if !errors.Is(err, baseErr) {
|
|
t.Fatalf("expected original redirect policy error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLRejectsUnsafeDestination(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = false
|
|
|
|
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "example.com", Path: "/hook"}}
|
|
err := wrapper.guardOutboundRequestURL(httpReq)
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected destination validation failure, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLAllowsValidatedDestination(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook"}}
|
|
err := wrapper.guardOutboundRequestURL(httpReq)
|
|
if err != nil {
|
|
t.Fatalf("expected validated destination to pass guard, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLRejectsUserInfo(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
httpReq := &http.Request{URL: &neturl.URL{Scheme: "http", Host: "127.0.0.1", User: neturl.UserPassword("user", "pass"), Path: "/hook"}}
|
|
err := wrapper.guardOutboundRequestURL(httpReq)
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected userinfo rejection, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPWrapperGuardOutboundRequestURLRejectsFragment(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
httpReq := &http.Request{URL: &neturl.URL{Scheme: "https", Host: "example.com", Path: "/hook", Fragment: "frag"}}
|
|
err := wrapper.guardOutboundRequestURL(httpReq)
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected fragment rejection, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeTransportErrorReason(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expected string
|
|
}{
|
|
{name: "nil error", err: nil, expected: "connection failed"},
|
|
{name: "dns error", err: errors.New("dial tcp: lookup gotify.example: no such host"), expected: "dns lookup failed"},
|
|
{name: "connection refused", err: errors.New("connect: connection refused"), expected: "connection refused"},
|
|
{name: "network unreachable", err: errors.New("connect: no route to host"), expected: "network unreachable"},
|
|
{name: "timeout", err: errors.New("context deadline exceeded"), expected: "request timed out"},
|
|
{name: "tls failure", err: errors.New("tls: handshake failure"), expected: "tls handshake failed"},
|
|
{name: "fallback", err: errors.New("some unexpected transport error"), expected: "connection failed"},
|
|
}
|
|
|
|
for _, testCase := range tests {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
actual := sanitizeTransportErrorReason(testCase.err)
|
|
if actual != testCase.expected {
|
|
t.Fatalf("expected %q, got %q", testCase.expected, actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildSafeRequestURLPreservesHostnameForTLS(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
destinationURL := &neturl.URL{
|
|
Scheme: "https",
|
|
Host: "example.com",
|
|
Path: "/webhook",
|
|
}
|
|
|
|
safeURL, hostHeader, err := wrapper.buildSafeRequestURL(destinationURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if safeURL.Hostname() != "example.com" {
|
|
t.Fatalf("expected hostname 'example.com' preserved in URL for TLS SNI, got %q", safeURL.Hostname())
|
|
}
|
|
|
|
if hostHeader != "example.com" {
|
|
t.Fatalf("expected host header 'example.com', got %q", hostHeader)
|
|
}
|
|
|
|
if safeURL.Scheme != "https" {
|
|
t.Fatalf("expected scheme 'https', got %q", safeURL.Scheme)
|
|
}
|
|
|
|
if safeURL.Path != "/webhook" {
|
|
t.Fatalf("expected path '/webhook', got %q", safeURL.Path)
|
|
}
|
|
}
|
|
|
|
func TestBuildSafeRequestURLDefaultsEmptyPathToSlash(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
destinationURL := &neturl.URL{
|
|
Scheme: "http",
|
|
Host: "localhost",
|
|
}
|
|
|
|
safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if safeURL.Path != "/" {
|
|
t.Fatalf("expected default path '/', got %q", safeURL.Path)
|
|
}
|
|
}
|
|
|
|
func TestBuildSafeRequestURLPreservesQueryString(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
destinationURL := &neturl.URL{
|
|
Scheme: "https",
|
|
Host: "example.com",
|
|
Path: "/hook",
|
|
RawQuery: "key=value",
|
|
}
|
|
|
|
safeURL, _, err := wrapper.buildSafeRequestURL(destinationURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if safeURL.RawQuery != "key=value" {
|
|
t.Fatalf("expected query 'key=value', got %q", safeURL.RawQuery)
|
|
}
|
|
}
|
|
|
|
func TestBuildSafeRequestURLRejectsNilDestination(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
_, _, err := wrapper.buildSafeRequestURL(nil)
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected validation failure for nil URL, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildSafeRequestURLRejectsEmptyHostname(t *testing.T) {
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
|
|
destinationURL := &neturl.URL{
|
|
Scheme: "https",
|
|
Host: "",
|
|
Path: "/hook",
|
|
}
|
|
|
|
_, _, err := wrapper.buildSafeRequestURL(destinationURL)
|
|
if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") {
|
|
t.Fatalf("expected validation failure for empty hostname, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildSafeRequestURLWithTLSServer(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverURL, _ := neturl.Parse(server.URL)
|
|
|
|
wrapper := NewNotifyHTTPWrapper()
|
|
wrapper.allowHTTP = true
|
|
|
|
safeURL, hostHeader, err := wrapper.buildSafeRequestURL(serverURL)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if safeURL.Host != serverURL.Host {
|
|
t.Fatalf("expected host %q preserved for TLS, got %q", serverURL.Host, safeURL.Host)
|
|
}
|
|
|
|
if hostHeader != serverURL.Host {
|
|
t.Fatalf("expected host header %q, got %q", serverURL.Host, hostHeader)
|
|
}
|
|
}
|