SSRF Protection in One Dialer Hook: Notes from a Zero-Dependency Go Toolkit

netkit collects tools that usually live on a dozen different websites: a DNS checker with propagation and SPF/DMARC/DKIM checks, an SSL checker that lists supported TLS versions and ciphers, a redirect tracer, an HTTP headers grader, a port checker, WHOIS/RDAP, and a few developer utilities like a JWT decoder and a cron calculator. Fourteen tools in one Go binary. Each has a web UI and a JSON API, and go.mod has no require lines at all.

This post covers two things: the one security problem every tool like this has, and what building on nothing but the standard library looks like in practice.

The problem: a server that fetches what users ask for

Half of these tools take a hostname or URL from the user and make a request to it. That is the textbook setup for server-side request forgery (SSRF). Ask the redirect checker to trace http://169.254.169.254/latest/meta-data/ and, on a cloud VM, you may get the machine's credentials back. Ask the port checker to scan 127.0.0.1 and it maps the services on the server itself.

The obvious fix is to look at the URL before fetching it:

// looks safe, isn't
u, _ := url.Parse(target)
ips, _ := net.LookupIP(u.Hostname())
for _, ip := range ips {
	if ip.IsPrivate() || ip.IsLoopback() {
		return errBlocked
	}
}
resp, err := http.Get(target) // resolves the name again, follows redirects

This has at least three holes:

The fix: check at the moment of connecting

Go's net.Dialer has a Control hook. It runs after the address has been resolved and right before the socket connects, and it receives the actual IP and port. If it returns an error, the connection is never made:

dialer := &net.Dialer{
	Timeout: 5 * time.Second,
	Control: func(_, address string, _ syscall.RawConn) error {
		host, _, _ := net.SplitHostPort(address)
		return CheckPublic(host)
	},
}
func CheckPublic(host string) error {
	ip, err := netip.ParseAddr(host)
	if err != nil {
		return errors.New("invalid ip " + strconv.Quote(host))
	}
	ip = ip.Unmap()
	if !ip.IsGlobalUnicast() || ip.IsPrivate() {
		return errors.New("address " + ip.String() + " is not public")
	}
	return nil
}

That one hook closes all three holes. It sees the IP the socket will really connect to, so rebinding doesn't help. It runs on every connection, so every redirect hop is checked too. And since every tool gets its network access from the same place, none of them can forget.

That shared place is a small Deps struct, built once at startup. Its HTTP clients use the guarded dialer, including a second client that doesn't follow redirects, used by the redirect tracer to record each hop itself:

transport := browserHeaders{&http.Transport{
	DialContext:         dialer.DialContext,
	TLSHandshakeTimeout: 5 * time.Second,
	ForceAttemptHTTP2:   true,
}}

return Deps{
	Resolver: net.DefaultResolver,
	Dialer:   dialer,
	HTTP:     &http.Client{Timeout: 10 * time.Second, Transport: transport},
	NoFollow: &http.Client{
		Timeout:       10 * time.Second,
		Transport:     transport,
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	},
}

IsGlobalUnicast rejects loopback, link-local (which includes the cloud metadata address), multicast and unspecified addresses. IsPrivate adds the RFC 1918 ranges and IPv6 unique local addresses. Unmap turns an IPv4-mapped IPv6 address like ::ffff:127.0.0.1 back into IPv4 first, so it can't sneak past either check.

What it doesn't cover yet: a few special ranges that Go still counts as global unicast, such as carrier-grade NAT (100.64.0.0/10) and the benchmarking range (198.18.0.0/15). On most hosting they're harmless, but if your server can reach them, add them to the deny list.

Building on the standard library only

No dependencies means every feature is built from the standard library. That's more of it than you might expect:

Each tool is a module built by a factory that receives the shared dependencies. Adding a tool is a new package and one more factory in main:

type Module struct {
	Name        string
	Title       string
	Description string
	UI          fs.FS
	Views       []View
	Routes      []Route
}

type Factory func(Deps) Module

func NewServer(d Deps, factories ...Factory) http.Handler {
	// build every module with the shared Deps, mount its routes,
	// wrap each handler in http.TimeoutHandler, serve its embedded UI
}

Every route is wrapped in http.TimeoutHandler, and the server sets explicit read, write and idle timeouts. A tool that talks to slow remote hosts must not be able to hold a connection open forever.

Shipping it

No dependencies and no cgo means a static binary. The image is a distroless base with that binary, running as a non-root user:

FROM golang:1.27.2-alpine AS builder
WORKDIR /build
COPY . .
ENV CGO_ENABLED=0 GOOS=linux
RUN go build -ldflags="-s -w" -trimpath -o app ./cmd/server

FROM gcr.io/distroless/static-debian13:nonroot
WORKDIR /app
COPY --from=builder /build/app .
USER nonroot
ENTRYPOINT ["/app/app"]

CI checks formatting, runs go vet, builds the image, pushes it to GitHub Container Registry and deploys to Docker Swarm. There's also a go test step, but the project has no tests yet, so for now that step only proves the code compiles. The SSRF check is the first thing that should get one.

Takeaway

If your server fetches URLs that users provide, don't validate the URL. Validate the connection. In Go, that's a Control function on the dialer, and it's the most valuable six lines in this project.

← All posts