mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
fa66181b88
* fix(webhooks): a DNS failure is retried, and a redirect may not turn the POST into a bodyless GET (BUG-3661, BUG-3662) BUG-3661: the owner path's pre-delivery URL check returned the resolver's error like a policy refusal, and attemptDeliver and checkRedirect made every validation error permanent, so a DNS timeout or SERVFAIL ended the endpoint's part in the event for good. The app path's dial turned resolver errors into ErrRefused, which attemptApp maps to permanent. Now ValidateWebhookURLContext returns a typed ResolveError from a lookup bound to the attempt's timeout (a seam, lookupIP), attemptDeliver and checkRedirect treat it as transient, and appfetch's dial returns resolver errors as a network error (ErrNotSent from Poster.Post: transient, unsent). Policy refusals stay permanent. BUG-3662: net/http follows a 301, 302 or 303 after a POST as a bodyless GET and keeps the headers, so the receiver got a GET with a signature for a body it never saw, and a 2xx was recorded as delivered. checkRedirect now refuses a hop that changes the method or drops the body (permanent: the receiver's configuration). 307 and 308 keep both and are followed. Tests: resolver failure transient at delivery, at a redirect and on the app poster, with policy controls staying permanent / refused; 301/302/303 refused without sending the GET; 307/308 deliver the POST with its body and signature. Each fix's mutant turns its test red. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(webhooks): a dial the address screen refuses is permanent, and one deadline covers the whole attempt (BUG-3661, codex r1) Medium: screenDialAddr refusals (a DNS answer that changed between the URL check and the dial) were plain errors, so the Do error counted as transient and a policy refusal was retried. They now wrap errDialBlocked, which attemptDeliver treats as permanent. Low: the pre-delivery lookup had its own deliveryTimeout before the request got another; both now run under one attempt context. TestBUG3661_DialTimeBlockIsPermanent (validation sees a public answer, the dialer resolves localhost) is red with the classification removed. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
98 lines
2.6 KiB
Go
98 lines
2.6 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/netpolicy"
|
|
)
|
|
|
|
// ResolveError is a hostname lookup that failed (BUG-3661): a timeout, a
|
|
// SERVFAIL, an unreachable resolver. It says nothing about whether the URL is
|
|
// allowed, so a delivery that hits one is transient, not refused; the policy's
|
|
// own refusals are plain errors.
|
|
type ResolveError struct {
|
|
Host string
|
|
Err error
|
|
}
|
|
|
|
func (e *ResolveError) Error() string {
|
|
return fmt.Sprintf("failed to resolve hostname %q: %v", e.Host, e.Err)
|
|
}
|
|
|
|
func (e *ResolveError) Unwrap() error { return e.Err }
|
|
|
|
// IsResolveError reports whether err is, or wraps, a ResolveError.
|
|
func IsResolveError(err error) bool {
|
|
var re *ResolveError
|
|
return errors.As(err, &re)
|
|
}
|
|
|
|
// lookupIP resolves a webhook hostname; a test replaces it.
|
|
var lookupIP = net.DefaultResolver.LookupIP
|
|
|
|
// ValidateWebhookURL checks that a webhook URL is safe to call.
|
|
// It rejects non-HTTP(S) schemes, URLs with credentials, private/reserved
|
|
// IPs (loopback, link-local, RFC1918, cloud metadata), and hostnames that
|
|
// resolve to private IPs.
|
|
func ValidateWebhookURL(rawURL string) error {
|
|
return ValidateWebhookURLContext(context.Background(), rawURL)
|
|
}
|
|
|
|
// ValidateWebhookURLContext is ValidateWebhookURL with the hostname lookup
|
|
// bound to ctx. A lookup that fails returns a *ResolveError.
|
|
func ValidateWebhookURLContext(ctx context.Context, rawURL string) error {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
// Scheme must be http or https
|
|
switch u.Scheme {
|
|
case "http", "https":
|
|
// ok
|
|
default:
|
|
return fmt.Errorf("unsupported scheme %q: only http and https are allowed", u.Scheme)
|
|
}
|
|
|
|
// Reject URLs with embedded credentials
|
|
if u.User != nil {
|
|
return fmt.Errorf("URLs with embedded credentials are not allowed")
|
|
}
|
|
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("URL must have a hostname")
|
|
}
|
|
|
|
// Check if host is a literal IP
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if isPrivateIP(ip) {
|
|
return fmt.Errorf("webhook URLs must not target private or reserved IP addresses")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Host is a name — resolve it and check all resulting IPs
|
|
ips, err := lookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return &ResolveError{Host: host, Err: err}
|
|
}
|
|
for _, ip := range ips {
|
|
if isPrivateIP(ip) {
|
|
return fmt.Errorf("hostname %q resolves to private/reserved IP %s", host, ip)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isPrivateIP reports whether a webhook may not be delivered to ip. The
|
|
// policy is the one shared with URL import (BUG-3358): see netpolicy.Blocked.
|
|
func isPrivateIP(ip net.IP) bool {
|
|
return netpolicy.Blocked(ip)
|
|
}
|