mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 00:47:47 +02:00
- Validate resolved IPs immediately before connecting. - Guard Shoutrrr requests during initialization and delivery. - Require admins for services without HTTP client support. - Test DNS rebinding, redirects, internal hosts, and authorization. - Upgrade Shoutrrr to 0.20.0 to support custom dialcontext
151 lines
4.2 KiB
Go
151 lines
4.2 KiB
Go
package alerts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/nicholas-fedor/shoutrrr/pkg/router"
|
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
|
)
|
|
|
|
var (
|
|
errInternalDestination = errors.New("Only admins can send to internal destinations")
|
|
errUnrestrictedService = errors.New("Only admins can use this notification service") // Restrict services w/o custom connection support
|
|
publicNotificationDialer = &net.Dialer{
|
|
Timeout: 10 * time.Second,
|
|
// Control checks each resolved address immediately before connecting.
|
|
Control: func(_, address string, _ syscall.RawConn) error { return checkNotificationAddress(address) },
|
|
}
|
|
publicNotificationClient = newPublicNotificationClient()
|
|
)
|
|
|
|
func newPublicNotificationClient() *http.Client {
|
|
return &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
Transport: &http.Transport{
|
|
// Do not use proxies: they can resolve the target themselves and
|
|
// bypass the destination check on our socket.
|
|
DialContext: publicNotificationDialer.DialContext,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func checkNotificationAddress(address string) error {
|
|
addr, err := netip.ParseAddrPort(address)
|
|
if err != nil || addr.Addr().Zone() != "" {
|
|
return errInternalDestination
|
|
}
|
|
ip := net.IP(addr.Addr().AsSlice())
|
|
if !ip.IsGlobalUnicast() || isInternalIP(ip) {
|
|
return errInternalDestination
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sendPublicNotification(rawURL, message string) error {
|
|
client := ¬ificationClient{Client: publicNotificationClient}
|
|
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client, DialContext: client.dialContext})
|
|
if err == nil {
|
|
if closer, ok := service.(io.Closer); ok {
|
|
defer closer.Close()
|
|
}
|
|
err = service.Send(message, &types.Params{})
|
|
}
|
|
// Some services format errors without preserving their error chain.
|
|
if client.blocked.Load() {
|
|
return errInternalDestination
|
|
}
|
|
return err
|
|
}
|
|
|
|
type notificationClient struct {
|
|
*http.Client
|
|
blocked atomic.Bool
|
|
}
|
|
|
|
func (c *notificationClient) Do(req *http.Request) (*http.Response, error) {
|
|
response, err := c.Client.Do(req)
|
|
if errors.Is(err, errInternalDestination) {
|
|
c.blocked.Store(true)
|
|
}
|
|
return response, err
|
|
}
|
|
|
|
func (c *notificationClient) dialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
|
conn, err := publicNotificationDialer.DialContext(ctx, network, address)
|
|
if errors.Is(err, errInternalDestination) {
|
|
c.blocked.Store(true)
|
|
}
|
|
return conn, err
|
|
}
|
|
|
|
func newPublicNotificationService(rawURL string, opts types.SenderOptions) (types.Service, error) {
|
|
r := &router.ServiceRouter{}
|
|
scheme, serviceURL, err := r.ExtractServiceName(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
service, err := r.NewService(scheme)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpSetter, httpOK := service.(types.HTTPClientSetter)
|
|
dialSetter, dialOK := service.(types.DialContextSetter)
|
|
if (!httpOK || opts.HTTPClient == nil) && (!dialOK || opts.DialContext == nil) {
|
|
return nil, errUnrestrictedService
|
|
}
|
|
if serviceURL.Scheme != scheme {
|
|
custom, ok := service.(types.CustomURLService)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", router.ErrCustomURLsNotSupported, scheme)
|
|
}
|
|
serviceURL, err = custom.GetServiceURLFromCustom(serviceURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Shoutrrr v0.20.0 CreateSenderWithOptions injects only AFTER Initialize.
|
|
// Matrix can log in during Initialize, so inject before it as well.
|
|
if httpOK {
|
|
httpSetter.SetHTTPClient(opts.HTTPClient)
|
|
}
|
|
if dialOK {
|
|
dialSetter.SetDialContext(opts.DialContext)
|
|
}
|
|
if err := service.Initialize(serviceURL, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
// Some initializers replace their HTTP client with a default client.
|
|
if httpOK {
|
|
httpSetter.SetHTTPClient(opts.HTTPClient)
|
|
}
|
|
if dialOK {
|
|
dialSetter.SetDialContext(opts.DialContext)
|
|
}
|
|
return service, nil
|
|
}
|
|
|
|
var cgnatNetwork = &net.IPNet{
|
|
IP: net.IPv4(100, 64, 0, 0),
|
|
Mask: net.CIDRMask(10, 32),
|
|
}
|
|
|
|
func isInternalIP(ip net.IP) bool {
|
|
return ip.IsPrivate() ||
|
|
ip.IsLoopback() ||
|
|
ip.IsUnspecified() ||
|
|
ip.IsLinkLocalUnicast() ||
|
|
ip.IsMulticast() ||
|
|
cgnatNetwork.Contains(ip)
|
|
}
|