Your Localhost Server Is Listening on Every Interface
Myth
Engineers treat a “localhost server” as a private process. You start python -m http.server 8000 to preview a static tree or hand a file to another tool on the same box. The browser opens http://localhost:8000 and that word is taken as a guarantee: nothing off this machine can connect.
The same habit shows up with ad-hoc Node scripts and MCP HTTP wrappers. If the tab says localhost, the socket is assumed to be loopback. Firewall rules stay untouched. The directory being served is often a home folder with SSH keys sitting one level up.
Reality
The string in the URL bar does not set the bind address. The kernel records whatever address the process passed to bind(2). Miss that argument and most runtimes pick every interface.
By default, the server binds itself to all interfaces. python -m http.server –bind 127.0.0.1 is the command that actually stays on loopback. (Python 3 docs, http.server)
Skip the flag and ss -tlnp reports 0.0.0.0:8000 or [::]:8000. That is INADDR_ANY. Every NIC accepts the handshake: wired, wireless, a VPN tunnel, a container bridge. Loopback in your browser only describes the path your client used. A second host on the LAN can open 192.168.x.x:8000 against the same PID. If the port is reachable from a public address, a scanner gets the same tree. Directory listings are on by default.
Some tools default to loopback. Uvicorn’s --host default is 127.0.0.1. flask run does the same, and Flask’s docs warn that --host=0.0.0.0 plus the debugger lets anyone on the path execute Python on your box. The stdlib HTTP server did not take that default. Node’s server.listen(8000) with no host argument follows the same POSIX rule: omit the address, get every interface.
Dual-stack hides a second socket. Bind :: on Linux with IPV6_V6ONLY left at 0 and IPv4-mapped connections land on the IPv6 listener. ss -4 looks clean. ss -tlnp still shows [::]:8000. CVE-2026-81735 in ByteDance’s UI-TARS-desktop hit this default: mcp-http-server used :: when no host was set, so Streamable HTTP and SSE bound every interface, with authentication optional.
A host firewall is a backup, not the bind. Packets that arrive on a published container port or a tunnel can skip the filter you think is in front. Fix the socket first.
How to protect
Pass the address every time. For the stdlib server:
python3 -m http.server 8000 --bind 127.0.0.1
ss -tlnp | awk 'NR==1 || /:8000/'
The ss line must show 127.0.0.1:8000 or [::1]:8000. 0.0.0.0, *, or [::] means you are still on every NIC. Check both families; an IPv6 wildcard will accept IPv4-mapped clients on a default Linux stack.
For a long-running unit, pin the peer set even if a future code change drops the bind flag:
# /etc/systemd/system/devhttp.service.d/loopback.conf
[Service]
IPAddressDeny=any
IPAddressAllow=127.0.0.1/32
IPAddressAllow=::1/128
Put a reverse proxy on loopback if the service must leave the box. Nginx or Caddy binds the public port; the app keeps 127.0.0.1. Do not serve $HOME. Do not leave Flask’s debugger attached to a wildcard. Treat a missing --bind / --host / listen(host) argument as a bug, not a convenience.