The SSRF blocklist had no escape hatch, so the thing webhooker is
mostly for — taking a public webhook and forwarding it to something
on your own network — could not be configured at all. Every private
address, Docker sibling and loopback service was permanently
unreachable as a delivery destination.
ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery
targets may reach despite the default blocklist. It is an allowlist
and only ever adds destinations: there is no boolean, and no value
disables SSRF protection wholesale. Empty, the guard behaves exactly
as before.
A fixed set of addresses is refused before the allowlist is
consulted, so no supplied CIDR opens one — not the exact address,
not a supernet, not 0.0.0.0/0 or ::/0. It is the two link-local
blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud
metadata endpoints that sit outside them: AWS's IPv6 IMDS at
fd00:ec2::254, which lives in ordinary ULA space, and Alibaba's
100.100.100.200, which lives in CGNAT. Allowlisting fd00::/8 or
100.64.0.0/10 (Tailscale's range) is an ordinary thing for an
operator to do and must not reopen instance-credential theft. The
IPv4-compatible (::a9fe:a9fe) and NAT64 (64:ff9b::a9fe:a9fe)
spellings of 169.254.169.254 are listed too, because To4() does not
normalise them into the link-local block the way it does the
IPv4-mapped form. Reaching any of these is credential theft rather
than delivery to an internal service.
The policy now lives in one function, Guard.checkIP, which both
target-creation validation and the delivery dialer call. The two
paths previously decided separately, which is how they came to
disagree about a destination. The guard is built once from config
and injected via fx into both the handlers and the delivery engine,
so there is a single instance and a single answer.
A set-but-unparseable value aborts startup naming the variable,
reusing the existing envPrefixList parser. A non-empty list is
logged at startup with the blocks spelled out, not counted, so the
hole is visible in the log of any deployment that has one.
Tests: an allowlisted loopback CIDR both validates and delivers to a
live server (and the same URL still fails without the allowlist); a
private address outside the listed block stays refused on both
paths; every unconditionally blocked address stays refused on both
paths under an allowlist that covers it, and the set itself is
pinned entry by entry; public addresses are unaffected either way;
and config coverage for parsing, startup abort, and the warning's
contents.
Go embeds the request URL in *url.Error, so any transport failure — DNS,
TLS, refused, timeout, SSRF dial block — persisted the full Slack webhook
URL into the per-webhook SQLite database via DeliveryResult.Error. That
field is tagged json:"error,omitempty", so a future REST API would have
served it.
maskURLError rebuilds the error preserving Op and the wrapped cause, so DNS
vs TLS vs timeout still read differently and errors.Is/As and Timeout()
keep working; only path, query and userinfo are dropped. Applied where the
errors are born, which covers both the Slack and HTTP targets. url.Parse
embeds the URL too, so ValidateTargetURL's parse branch gets the same
treatment.
The SSRF rejection log now logs the masked URL, and source_logs.html
receives view types rather than raw rows, so no config blob is reachable
from that template.
MaskURL is now the single masker for the whole tree.