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 redirectsThis has at least three holes:
- DNS rebinding. The check resolves the name, then
http.Getresolves it again. A malicious DNS server can answer with a public IP the first time and127.0.0.1the second time. - Redirects. A public URL can redirect to an internal one. The check looked at the first URL only.
- Every tool needs to remember. The port checker, the SSL checker and the site-files fetcher each have to call the check before connecting, and the next tool you add will forget.
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:
- Routing is
net/http'sServeMuxwith method and wildcard patterns. For an API this size, nothing is missing. - DNS propagation uses one
net.Resolverper public resolver, each withPreferGoand a customDialpinned to that server's address. Fourteen resolvers are queried in parallel, and the answers are compared to find the majority. - TLS cipher enumeration is
crypto/tlswith one cipher suite per handshake, including the insecure ones, with a semaphore capping it at eight probes at a time. TLS 1.3 doesn't let a client choose suites that way, so for 1.3 it reports the one that was negotiated. - The UI is HTML, CSS and a little JavaScript per tool, embedded with
embed.FS. Each view renders as a custom element with declarative shadow DOM, so one tool's styles can't leak into another's. Pages are rendered once at startup into a byte buffer and served from memory.
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.