fix(sec): санитизация Telegram-ошибок, SSRF-guard Webhook, чистка логов test-канала, go mod tidy, histogram-бакеты
This commit is contained in:
@@ -4,15 +4,77 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Webhook delivers notifications as a JSON POST of the Event to a
|
||||
// project-configured URL. Config is {"url": "..."}. secret is currently
|
||||
// unused (reserved for future request signing) and is never logged.
|
||||
//
|
||||
// The destination URL is project-controlled (any project owner can set it),
|
||||
// so it is treated as untrusted input: isAllowedURL blocks requests to
|
||||
// loopback/private/link-local/unspecified addresses to prevent SSRF against
|
||||
// internal services and cloud metadata endpoints (e.g. 169.254.169.254).
|
||||
// Redirects are not followed, since a redirect response could otherwise be
|
||||
// used to bypass the destination check.
|
||||
type Webhook struct {
|
||||
HTTP *http.Client
|
||||
|
||||
// allowPrivate disables the SSRF guard. It exists only so tests can
|
||||
// exercise Send happy-paths against httptest servers, which listen on
|
||||
// loopback. Production Dispatchers (NewDispatcher) must never set this.
|
||||
allowPrivate bool
|
||||
}
|
||||
|
||||
// isAllowedURL rejects any URL that is not a plain http/https request to a
|
||||
// public, resolvable address. It resolves hostnames and checks every
|
||||
// returned address — a hostname that resolves to even one
|
||||
// private/loopback/link-local/unspecified address is rejected, since DNS
|
||||
// answers are attacker-influenceable (rebinding) and partial trust is not
|
||||
// safe.
|
||||
func isAllowedURL(rawurl string) error {
|
||||
u, err := url.Parse(rawurl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: invalid url: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return errors.New("webhook: destination not allowed")
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return errors.New("webhook: destination not allowed")
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
ips = []net.IP{ip}
|
||||
} else {
|
||||
resolved, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return errors.New("webhook: destination not allowed")
|
||||
}
|
||||
ips = resolved
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isDisallowedIP(ip) {
|
||||
return errors.New("webhook: destination not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDisallowedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast()
|
||||
}
|
||||
|
||||
func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
|
||||
@@ -22,6 +84,11 @@ func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string,
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
if !w.allowPrivate {
|
||||
if err := isAllowedURL(c.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -31,7 +98,17 @@ func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string,
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := w.HTTP.Do(req)
|
||||
|
||||
client := w.HTTP
|
||||
if client.CheckRedirect == nil {
|
||||
clientCopy := *client
|
||||
clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
client = &clientCopy
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user