Files
pad/internal/webhooks/validate.go
T
xarmian fa66181b88 fix(webhooks): a DNS failure is retried, and a redirect may not turn the POST into a bodyless GET (BUG-3661, BUG-3662) (#2163)
* 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
2026-10-14 10:05:08 -04:00

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)
}