mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:41:39 +00:00
119986fa7e
The webhook notifier would previously accept any operator-configured URL
and hand it to http.Client without validation. That exposed two
SSRF classes (CWE-918):
* Reserved-address reachability — a misconfigured or adversarial
webhook URL pointing at 127.0.0.1, ::1, 169.254.169.254 (cloud
metadata), or 0.0.0.0 would succeed, exfiltrating request bodies
to local services or leaking short-lived cloud credentials.
* DNS rebinding — a hostname resolving to a public IP at validation
time and to a reserved IP at dial time would bypass any
URL-string-only check.
Fix installs two independent layers:
* validation.ValidateSafeURL runs at config-ingest time and before
every outbound POST. It rejects non-HTTP(S) schemes, empty hosts,
and literal reserved-IP hosts with a clear operator-facing error.
This is a fast early diagnostic.
* validation.SafeHTTPDialContext is installed on the webhook
http.Transport. It re-resolves the host at dial time, rejects any
resolved address whose address lies in a reserved range (loopback,
link-local, multicast, broadcast, unspecified, IPv6
link-local/multicast), and pins the resolved IP into the final
dial address so the TLS handshake targets the exact IP the guard
approved. This is the authoritative, TOCTOU-safe defence against
DNS rebinding.
The two layers are complementary — validateURL fails fast on obvious
misconfiguration; SafeHTTPDialContext fails closed when DNS changes
between validation and dial.
The existing unexported isReservedIP helper in
internal/service/network_scan.go is extracted into
internal/validation.IsReservedIP with byte-identical behaviour so the
webhook notifier and the network scanner share a single authoritative
reserved-address list. RFC 1918 ranges remain intentionally allowed
(certctl's self-hosted design). Broader unspecified / IPv6 link-local
coverage lives only in the stricter dial-time policy, where it belongs
for outbound HTTP egress.
Test seam: Connector gains an unexported validateURL func field and a
same-package newForTest constructor that installs a permissive
validator and the stdlib default transport. Production callers cannot
reach this constructor because it is unexported; only same-package
tests (package webhook) can use it. Same-package happy-path tests call
newForTest so they can point at httptest loopback servers without
being blocked by the production guard. The four SSRF-rejection tests
that verify the guard itself still call New so they exercise the real,
strict validator. This keeps the production SSRF defence
unconditionally on in real code while preserving legitimate unit-test
coverage.
Tests
-----
* internal/validation/ssrf_test.go (new) — 16-subtest pin on
IsReservedIP that is byte-identical with the original network-
scanner behaviour; ValidateSafeURL accept/reject matrix covering
HTTPS/HTTP, reserved-literal IPv4/IPv6, dangerous schemes
(file/gopher/ftp/javascript/data/ldap/dict/jar), missing hosts,
and malformed inputs; SafeHTTPDialContext rejects literal reserved
addresses and hosts resolving to reserved addresses (DNS-rebinding
coverage via localhost).
* internal/connector/notifier/webhook/webhook_test.go — happy-path
tests switched to newForTest; production-guard SSRF-rejection
tests (TestValidateConfig_RejectsReservedURLs,
TestValidateConfig_RejectsDangerousScheme,
TestPostWebhook_RejectsReservedURL,
TestPostWebhook_RejectsDangerousScheme) continue to call New so
they exercise the unconditionally-installed production validator.
Wire-format invariants preserved
--------------------------------
* Outbound HTTP request shape (method, headers, body, HMAC
signature) unchanged.
* network_scan.go behaviour unchanged — validation.IsReservedIP is
byte-identical with the deleted helper.
* RFC 1918 (10/8, 172.16/12, 192.168/16) remain allowed for both
outbound webhook and CIDR expansion, matching the self-hosted
design.
Verification
------------
* go test -race ./internal/validation/... ./internal/connector/
notifier/webhook/... ./internal/service/... — green.
* Full-suite go test -race ./... — green (GOTMPDIR=/dev/shm to
sidestep full /tmp on the sandbox host).
* Coverage gates pass: service 68.8% >= 55%, handler 83.6% >= 60%,
domain 82.0% >= 40%, middleware 63.8% >= 30%. Overall 67.8%.
Webhook package 91.5% line coverage; validation package
ValidateSafeURL/SafeHTTPDialContext 78-100% per function.
* govulncheck ./... — no vulnerabilities found.
* golangci-lint run on touched H-4 production code — clean. Pre-
existing errcheck/gosimple warnings in scope-adjacent files
(webhook_test.go:270 w.Write, network_scan.go:120/173/265/305)
verified against 3853b74 to predate this commit; left alone per
scope guard.
Operational notes
-----------------
* No migration needed. The guard is pure Go code; existing webhook
configs continue to work unless they point at reserved addresses,
in which case they now fail closed with a clear error.
* Existing operators who rely on webhook POST to 127.0.0.1 or
::1 (e.g., local receivers on the same host as certctl-server)
must expose their receiver on an RFC 1918 address or public IP.
This is deliberate — the threat model for webhook notifiers
includes untrusted operator-supplied URLs.
Scope guard: H-4 only. H-5, H-6, M-*, L-*, and I-* findings remain
open and are tracked separately. No drive-by refactors.
213 lines
7.4 KiB
Go
213 lines
7.4 KiB
Go
package validation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// IsReservedIP reports whether the given IP falls inside a range that
|
|
// outbound HTTP egress (and the network-scanner CIDR expander) MUST treat
|
|
// as unreachable: loopback, link-local (including cloud-provider metadata
|
|
// endpoints at 169.254.169.254), multicast, and broadcast.
|
|
//
|
|
// RFC 1918 ranges (10/8, 172.16/12, 192.168/16) are intentionally NOT
|
|
// treated as reserved. certctl is designed to manage certificates inside
|
|
// private networks and filtering private address space would break the
|
|
// primary use case. The threat model here is outbound HTTP to
|
|
// cloud-metadata or localhost services, not general network reachability.
|
|
//
|
|
// This function is byte-identical in behaviour to the previous unexported
|
|
// copy in internal/service/network_scan.go. It is exported here so both
|
|
// the network scanner and the webhook notifier share a single
|
|
// authoritative implementation. Broader IPv6 coverage and unspecified-
|
|
// address handling live in SafeHTTPDialContext, where stricter policy is
|
|
// appropriate for outbound HTTP egress.
|
|
func IsReservedIP(ip net.IP) bool {
|
|
// Loopback: 127.0.0.0/8 (and ::1 via IsLoopback).
|
|
if ip.IsLoopback() {
|
|
return true
|
|
}
|
|
|
|
// Link-local: 169.254.0.0/16 (includes cloud metadata 169.254.169.254).
|
|
if linkLocal := net.ParseIP("169.254.0.0"); linkLocal != nil {
|
|
if _, linkLocalNet, _ := net.ParseCIDR("169.254.0.0/16"); linkLocalNet != nil {
|
|
if linkLocalNet.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multicast: 224.0.0.0/4.
|
|
if multicast := net.ParseIP("224.0.0.0"); multicast != nil {
|
|
if _, multicastNet, _ := net.ParseCIDR("224.0.0.0/4"); multicastNet != nil {
|
|
if multicastNet.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Broadcast: 255.255.255.255.
|
|
if ip.String() == "255.255.255.255" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isReservedIPForDial applies IsReservedIP plus additional ranges that are
|
|
// meaningful for outbound HTTP egress but were not part of the original
|
|
// network-scanner filter: the unspecified address (0.0.0.0 / ::) and IPv6
|
|
// link-local / multicast ranges. Kept private so IsReservedIP stays
|
|
// byte-identical with the previous scanner behaviour.
|
|
func isReservedIPForDial(ip net.IP) bool {
|
|
if ip == nil {
|
|
return true
|
|
}
|
|
if IsReservedIP(ip) {
|
|
return true
|
|
}
|
|
if ip.IsUnspecified() {
|
|
return true
|
|
}
|
|
// IPv6 link-local fe80::/10.
|
|
if _, n, err := net.ParseCIDR("fe80::/10"); err == nil && n.Contains(ip) {
|
|
return true
|
|
}
|
|
// IPv6 multicast ff00::/8.
|
|
if _, n, err := net.ParseCIDR("ff00::/8"); err == nil && n.Contains(ip) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateSafeURL parses rawURL and rejects anything that would let an
|
|
// attacker aim an outbound HTTP client at a SSRF-sensitive destination
|
|
// (CWE-918). Guards enforced:
|
|
//
|
|
// 1. The scheme must be http or https. Schemes like file://, gopher://,
|
|
// ftp://, data:, javascript:, ldap://, and dict:// are rejected outright;
|
|
// webhook delivery only speaks HTTP(S).
|
|
// 2. A hostname must be present. Empty-host URLs like "http:///foo" are
|
|
// rejected to prevent ambiguous defaulting.
|
|
// 3. If the host is a literal IP address, the IP must not be reserved
|
|
// (see isReservedIPForDial). This stops the obvious 127.0.0.1 / ::1 /
|
|
// 169.254.169.254 / 0.0.0.0 attacks at config time.
|
|
// 4. If the host is a DNS name and resolution succeeds, every resolved
|
|
// A/AAAA record must be non-reserved. A single reserved result is
|
|
// enough to reject. Resolution failure is tolerated (offline CI
|
|
// environments, short-lived test servers) — the authoritative
|
|
// enforcement runs at dial time anyway.
|
|
//
|
|
// The DNS resolution check here is a best-effort early diagnostic. The
|
|
// authoritative, TOCTOU-safe enforcement is SafeHTTPDialContext, which
|
|
// re-checks after resolution at dial time and defeats DNS rebinding.
|
|
// Callers that need SSRF-safe HTTP egress should use BOTH
|
|
// ValidateSafeURL (at config ingestion) AND SafeHTTPDialContext
|
|
// (installed on http.Transport).
|
|
func ValidateSafeURL(rawURL string) error {
|
|
if rawURL == "" {
|
|
return fmt.Errorf("url is required")
|
|
}
|
|
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid url: %w", err)
|
|
}
|
|
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf("url scheme %q is not allowed; only http and https are permitted", u.Scheme)
|
|
}
|
|
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("url must include a host")
|
|
}
|
|
|
|
// Literal IP? Reject if reserved (strict policy for outbound egress).
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if isReservedIPForDial(ip) {
|
|
return fmt.Errorf("url host resolves to a reserved address and cannot be used")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DNS name. Resolve and reject if any answer is reserved.
|
|
ips, err := net.LookupIP(host)
|
|
if err != nil {
|
|
// Resolution failure is not itself a SSRF signal; let the dial-time
|
|
// DialContext handle the final decision. This keeps the validator
|
|
// tolerant of offline validation environments (CI, tests) while
|
|
// still blocking clearly-bad literal-IP URLs above.
|
|
return nil
|
|
}
|
|
for _, ip := range ips {
|
|
if isReservedIPForDial(ip) {
|
|
return fmt.Errorf("url host resolves to a reserved address and cannot be used")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SafeHTTPDialContext returns a DialContext function suitable for
|
|
// installing on an http.Transport. Every dial attempt resolves the host
|
|
// again and rejects any connection whose resolved IP lies inside a
|
|
// reserved range. This is the authoritative SSRF / DNS-rebinding guard:
|
|
// even if ValidateSafeURL was bypassed, or if DNS changed between
|
|
// validation and dial, the outbound connection will fail closed.
|
|
//
|
|
// The timeout argument bounds both the resolution and the underlying TCP
|
|
// dial. Pass 0 to use a sensible default (10s).
|
|
func SafeHTTPDialContext(timeout time.Duration) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
dialer := &net.Dialer{
|
|
Timeout: timeout,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid dial address %q: %w", addr, err)
|
|
}
|
|
|
|
// If the host is already a literal IP, check it directly.
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if isReservedIPForDial(ip) {
|
|
return nil, fmt.Errorf("refusing to dial reserved address %s", ip.String())
|
|
}
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
|
|
// Resolve and reject any answer that lands in a reserved range.
|
|
// We then dial an explicit resolved IP so a racing DNS change
|
|
// cannot substitute a different (and possibly reserved) answer
|
|
// between our check and the actual TCP dial.
|
|
resCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
ips, err := (&net.Resolver{}).LookupIP(resCtx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve %s: %w", host, err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, fmt.Errorf("no addresses found for %s", host)
|
|
}
|
|
for _, ip := range ips {
|
|
if isReservedIPForDial(ip) {
|
|
return nil, fmt.Errorf("refusing to dial %s: resolves to reserved address %s", host, ip.String())
|
|
}
|
|
}
|
|
|
|
// Dial the first non-reserved resolved IP directly, pinning the
|
|
// target so later DNS changes cannot redirect us.
|
|
pinned := net.JoinHostPort(ips[0].String(), port)
|
|
return dialer.DialContext(ctx, network, pinned)
|
|
}
|
|
}
|