notification: add webhook channel sender
WebhookSender posts a JSON payload to a user-configured URL with an optional HMAC-SHA256 signature header. Outbound requests go through a SafeHTTP transport that blocks loopback, link-local, and private address ranges to prevent SSRF against internal services.
This commit is contained in:
parent
0eacde4dd1
commit
ab97185611
3 changed files with 368 additions and 0 deletions
101
internal/notification/httpjson.go
Normal file
101
internal/notification/httpjson.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Shared by both webhook and UnifiedPush.
|
||||
type httpJSONPayload struct {
|
||||
Event string `json:"event"`
|
||||
Checker string `json:"checker"`
|
||||
Domain string `json:"domain"`
|
||||
Target happydns.CheckTarget `json:"target"`
|
||||
OldStatus happydns.Status `json:"oldStatus"`
|
||||
NewStatus happydns.Status `json:"newStatus"`
|
||||
States []happydns.CheckState `json:"states,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DashboardURL string `json:"dashboardUrl,omitempty"`
|
||||
}
|
||||
|
||||
func buildHTTPPayload(p *NotificationPayload, dashboardURL string) httpJSONPayload {
|
||||
return httpJSONPayload{
|
||||
Event: "status_change",
|
||||
Checker: p.CheckerID,
|
||||
Domain: p.DomainName,
|
||||
Target: p.Target,
|
||||
OldStatus: p.OldStatus,
|
||||
NewStatus: p.NewStatus,
|
||||
States: p.States,
|
||||
Timestamp: time.Now(),
|
||||
DashboardURL: dashboardURL,
|
||||
}
|
||||
}
|
||||
|
||||
// decorate runs after marshal so it can sign the exact bytes (e.g. HMAC).
|
||||
func postJSON(ctx context.Context, client *http.Client, url string, body any, decorate func(*http.Request, []byte)) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if decorate != nil {
|
||||
decorate(req, raw)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testPayload(rcpt Recipient) *NotificationPayload {
|
||||
return &NotificationPayload{
|
||||
Recipient: rcpt,
|
||||
CheckerID: "test",
|
||||
DomainName: "example.com",
|
||||
OldStatus: happydns.StatusOK,
|
||||
NewStatus: happydns.StatusWarn,
|
||||
Annotation: "This is a test notification from happyDomain.",
|
||||
}
|
||||
}
|
||||
132
internal/notification/safe_http.go
Normal file
132
internal/notification/safe_http.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// We drain the response only for keep-alive; body is unused.
|
||||
const maxResponseBodyBytes = 64 * 1024
|
||||
|
||||
var errBlockedAddress = errors.New("address resolves to a blocked range")
|
||||
|
||||
// Reject non-http(s), missing host, or private/loopback IP literals; DNS hosts re-checked at dial time.
|
||||
func validateOutboundURL(rawURL string) (*url.URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported scheme %q (only http and https are allowed)", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return nil, errors.New("URL has no host")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if !isPublicIP(ip) {
|
||||
return nil, errBlockedAddress
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsPrivate() || ip.IsInterfaceLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
// Block IPv4 broadcast 255.255.255.255 explicitly (not covered above).
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Re-check resolved IP to defeat DNS rebinding.
|
||||
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
resolver := net.DefaultResolver
|
||||
ips, err := resolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicIP(ip) {
|
||||
return nil, fmt.Errorf("dial %s: %w", address, errBlockedAddress)
|
||||
}
|
||||
}
|
||||
// Pin the dial to a validated IP rather than letting the dialer re-resolve.
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("dial %s: no usable address", address)
|
||||
}
|
||||
|
||||
// Refuses private/loopback addresses and re-validates each redirect hop.
|
||||
func newSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
if _, err := validateOutboundURL(req.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
135
internal/notification/webhook_sender.go
Normal file
135
internal/notification/webhook_sender.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Reserved by the HTTP client or used to spoof outbound identity (smuggling/host-routing risk).
|
||||
var disallowedWebhookHeaders = map[string]struct{}{
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
"content-encoding": {},
|
||||
"transfer-encoding": {},
|
||||
"connection": {},
|
||||
"upgrade": {},
|
||||
"te": {},
|
||||
"trailer": {},
|
||||
}
|
||||
|
||||
func validateHeader(k, v string) error {
|
||||
if k == "" {
|
||||
return errors.New("empty header name")
|
||||
}
|
||||
if strings.ContainsAny(k, "\r\n") || strings.ContainsAny(v, "\r\n") {
|
||||
return fmt.Errorf("header %q contains CR/LF", k)
|
||||
}
|
||||
if _, blocked := disallowedWebhookHeaders[strings.ToLower(k)]; blocked {
|
||||
return fmt.Errorf("header %q is not allowed", k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const ChannelTypeWebhook happydns.NotificationChannelType = "webhook"
|
||||
|
||||
type WebhookConfig struct {
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
// HMAC-SHA256 signing key.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
// Set only by RedactConfig — never stored or accepted on input.
|
||||
HasSecret bool `json:"hasSecret,omitempty"`
|
||||
}
|
||||
|
||||
func (c WebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
if _, err := validateOutboundURL(c.URL); err != nil {
|
||||
return fmt.Errorf("webhook URL: %w", err)
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
if err := validateHeader(k, v); err != nil {
|
||||
return fmt.Errorf("webhook header: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dashboardURL is captured here — server identity, not per-notification data.
|
||||
type WebhookSender struct {
|
||||
client *http.Client
|
||||
dashboardURL string
|
||||
}
|
||||
|
||||
func NewWebhookSender(dashboardURL string) *WebhookSender {
|
||||
return &WebhookSender{
|
||||
client: newSafeHTTPClient(10 * time.Second),
|
||||
dashboardURL: dashboardURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebhookSender) Type() happydns.NotificationChannelType { return ChannelTypeWebhook }
|
||||
|
||||
func (s *WebhookSender) RedactConfig(cfg WebhookConfig) WebhookConfig {
|
||||
cfg.HasSecret = cfg.Secret != ""
|
||||
cfg.Secret = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Preserve stored secret on empty submit; client never receives it back, so absence means "no change".
|
||||
func (s *WebhookSender) MergeForUpdate(existing, incoming WebhookConfig) WebhookConfig {
|
||||
if incoming.Secret == "" {
|
||||
incoming.Secret = existing.Secret
|
||||
}
|
||||
incoming.HasSecret = false
|
||||
return incoming
|
||||
}
|
||||
|
||||
func (s *WebhookSender) Send(ctx context.Context, c WebhookConfig, payload *NotificationPayload) error {
|
||||
return postJSON(ctx, s.client, c.URL, buildHTTPPayload(payload, s.dashboardURL), func(req *http.Request, body []byte) {
|
||||
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
|
||||
for k, v := range c.Headers {
|
||||
// Defense in depth: catches stored channels that pre-date Validate().
|
||||
if err := validateHeader(k, v); err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if c.Secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(c.Secret))
|
||||
mac.Write(body)
|
||||
req.Header.Set("X-Happydomain-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue