Unix Domain Sockets vs TCP on Loopback: The Localhost Attack Surface Most Go Services Ignore
Somewhere in most Go services there's a line like this, added without much thought because it "isn't exposed to the network":
log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil))
Debug pprof endpoints, Prometheus metrics, admin control planes, internal RPC between two processes on the same box: all of them tend to end up bound to loopback on the theory that "localhost" means "private". It's a reasonable-sounding assumption, and it's wrong in exactly the cases where it matters most.
Loopback is a network interface, not a permission boundary
TCP on 127.0.0.1 still goes through the full socket API: bind, listen, accept. The kernel doesn't ask who's connecting before handing you the file descriptor. Any process that can open a socket in the same network namespace can connect to any TCP port bound to loopback in that namespace, regardless of which user owns it. There's no equivalent of a file's owner/group/mode bits. A service running as your user and a service running as a completely unrelated, unprivileged user on the same host are, from the loopback stack's point of view, indistinguishable.
This is exactly the shape of bug that has made unauthenticated Redis instances such a reliable SSRF target over the years: a web application fetches an attacker-controlled URL, the URL points at 127.0.0.1:6379, and because "it's only reachable from localhost" was treated as the access control, there wasn't one. The HTTP client doesn't need permission from the OS to open that connection. It just needs the port number.
Contrast that with a Unix domain socket. A UDS is a real filesystem object. It has an owner, a group, and mode bits, and it lives in a directory that has its own permissions on top of that. To connect to it at all, a process needs whatever open()/connect() on that path would require: readable/searchable parent directories and, depending on mode, write access to the socket file itself. That's a real access control decision, enforced by the same mechanism that protects every other file on the system, not a hopeful assumption about network topology.
Locking a Go UDS listener down properly
The socket file net.Listen("unix", ...) creates inherits the current umask, which on most systems means it's group- and world-writable enough to be unpleasant. Tighten it explicitly, and clean up any stale socket left behind by a previous crashed process:
package main
import (
"errors"
"fmt"
"net"
"os"
)
func listenUnix(path string) (net.Listener, error) {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("removing stale socket: %w", err)
}
l, err := net.Listen("unix", path)
if err != nil {
return nil, fmt.Errorf("listening on %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
l.Close()
return nil, fmt.Errorf("restricting socket permissions: %w", err)
}
return l, nil
}
0600 in a directory only your own process can write to means only that user can even reach the socket. That's already a meaningfully smaller attack surface than an unauthenticated TCP port that anyone in the namespace can probe.
The bit TCP loopback genuinely can't do: peer credentials
The more interesting property of a UDS on Linux isn't the filesystem permissions, it's SO_PEERCRED. When a process connects to a Unix socket, the kernel records the connecting process's UID, GID and PID at the moment connect() was called, and you can retrieve them on the accepted connection. TCP has nothing comparable: there is no syscall that tells you "the process on the other end of this loopback connection is UID 1000, PID 4821". IP doesn't carry process identity, because it was never designed to run two ends on the same kernel.
Go's standard library doesn't expose SO_PEERCRED directly, but golang.org/x/sys/unix, the Go team's own extended syscall package, does:
package main
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// peerCredentials is Linux-specific: SO_PEERCRED has no direct
// equivalent on other platforms (macOS/BSD expose LOCAL_PEERCRED
// via getpeereid instead).
func peerCredentials(conn net.Conn) (*unix.Ucred, error) {
uc, ok := conn.(*net.UnixConn)
if !ok {
return nil, fmt.Errorf("not a unix socket connection")
}
raw, err := uc.SyscallConn()
if err != nil {
return nil, err
}
var cred *unix.Ucred
var sockErr error
if err := raw.Control(func(fd uintptr) {
cred, sockErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
}); err != nil {
return nil, err
}
return cred, sockErr
}
With that, an admin API can reject connections from the wrong UID before it even looks at the request body, with no token, no shared secret, no TLS client certificate to provision or rotate. It's cheap authentication that the kernel already did the work for. Worth noting the credentials are captured at connect time, not read time, so this tells you who opened the connection, not necessarily who's still driving it moment to moment, which matters if a socket is expected to be long-lived and handed off between processes (it normally isn't, but it's the kind of assumption worth stating rather than leaving implicit).
The container wrinkle: localhost isn't always yours alone
This is where the TCP-loopback assumption breaks in a way that catches out people who've never run two unrelated users on the same host. In Kubernetes, containers within the same pod share a network namespace. That's the entire mechanism by which "localhost" works between an application container and its sidecars: they're genuinely on the same loopback interface. A metrics exporter, a service mesh proxy, and a logging sidecar injected by a platform team you don't control can all reach each other's 127.0.0.1 ports, because as far as the kernel is concerned they're one network stack with several processes attached.
If your admin endpoint on 127.0.0.1:9090 was unauthenticated because "only my own process talks to it", that assumption quietly stopped being true the day someone added a sidecar to the pod. A UDS doesn't have this problem in the same way: it requires an explicit filesystem path, usually via a shared volume mount, so another container in the pod can only reach it if you deliberately mounted the same volume into both containers. That's an opt-in decision visible in the pod spec, not an implicit property of how pod networking works.
One caveat: abstract sockets don't give you this for free
Linux also supports "abstract namespace" sockets, addressed with a name prefixed by a null byte rather than a filesystem path (in Go, net.Listen("unix", "\x00myservice")). They're convenient because there's no stale socket file to clean up on crash. But they have no filesystem entry, which means no owner, no mode bits, nothing to set permissions on. Any process in the same network namespace can connect, exactly like TCP loopback. Abstract sockets buy you tidiness, not access control, so treat them the same as loopback TCP from a threat-modelling point of view.
None of this means TCP on loopback is always wrong. On a single-tenant VM with no other users and nothing SSRF-capable talking to it, it's simple, it works with every HTTP tool you already have, and adding a UDS would be effort spent on a threat that isn't there. The failure mode is treating "bound to 127.0.0.1" as a security boundary by default, rather than as a convenience that happens to double as one only when you're genuinely the only thing on that loopback interface.