Documentation
¶
Overview ¶
Package egressproxy enforces the egress allowlist by **name** rather than by address.
The firewall it replaces resolves each allowlisted domain once at container start and permits those addresses. Two things follow, both confirmed against a running sandbox:
- A host sharing an allowlisted address is reachable. `gist.github.com` — a write endpoint, and so an exfiltration channel — answers under the baseline because it shares `github.com`'s address. Nothing inspects SNI, and which domains share a CDN address is something an attacker can shop for.
- Names resolve once, so a rotating record breaks a domain the user *did* allowlist, mid-session, with an error that looks like a network outage.
Both are the same bug: an address is not a name. This package reads the name the client actually asked for and decides on that.
Where it runs ¶
Inside the sandbox container, as its own unprivileged uid. The firewall entrypoint — already running as root before it drops privileges — allows egress only from the proxy's uid and REDIRECTs everything else to it. The agent cannot go around it, because "around it" would mean being a different uid.
That placement is deliberate. A host-side proxy dies with the CLI, which breaks `--detach` (the case that is unattended, so the case that most needs it); a sidecar container needs a per-run docker network, which reintroduces the orphaned-network cleanup this tool is currently free of. In-container costs neither.
What it does NOT do ¶
It does not terminate TLS. It reads the hostname from the handshake and then forwards bytes it cannot decrypt. That is enough to enforce an allowlist by name and to re-check every hop of a redirect chain — the client opens a *new* connection for a redirected host, so the new name is checked like any other.
Injecting a credential would require terminating TLS with a private CA in the container's trust store, which is a much larger decision: it puts every prompt, every response, the real credentials and a CA private key in one process. That is tracked separately and deliberately not started here.
Index ¶
- Constants
- Variables
- func EmbeddedFiles() []string
- func GeneratedSources() string
- func OriginalDestination(c net.Conn) (net.IP, int, error)
- func SNIFromClientHello(b []byte) (string, error)
- func SourceOf(name string) (string, error)
- func WriteBuildContext(dir string, write func(name string, data []byte) error) error
- type Decision
- type Matcher
- type Server
Constants ¶
const ( // LogLinePrefix begins every decision line the proxy writes to stderr. LogLinePrefix = "sandbox-cli: egress " // DenyLinePrefix begins a line reporting a refused connection. This is what // internal/runtime matches on, and the reason these constants are exported. DenyLinePrefix = LogLinePrefix + denyVerb + " " )
The shape of the lines the proxy writes, defined once.
A decision line is `LogLinePrefix + Decision.String()`, and it used to be assembled from three separate literals: the prefix in the proxy's `main` (embed.go's mainSource), the verb in Decision.String, and a hand-written copy of their concatenation in internal/runtime, which counts refusals by matching it. Changing either of the first two would have sent that counter silently to zero with every test still green — the drift the package doc in internal/agents/bootstrap.go was written about, in a place where the symptom is an audit field that reads 0 instead of a crash.
All three now derive from the constants here. This file is in embed.go's `//go:embed` list because the proxy compiled into the image uses them too, and TestEmbeddedSourcesAreComplete requires anything the proxy needs to ship. That has a price worth knowing: internal/image hashes the embedded proxy sources into the base-image tag, so touching this file changes the tag and costs users one rebuild.
Variables ¶
var ErrNoHostname = errors.New("connection carries no hostname to check")
ErrNoHostname is returned when a connection carries no name to decide on — a TLS handshake with no SNI, or a plain HTTP request with no Host header.
It is an error rather than a fallback to the destination address. Falling back would recreate exactly the hole this package exists to close: an unnamed connection to an allowlisted address would be permitted, and "connect by IP" is the most obvious way to try to evade a name-based allowlist.
Functions ¶
func EmbeddedFiles ¶
func EmbeddedFiles() []string
EmbeddedFiles lists what will ship, for the completeness test.
func GeneratedSources ¶
func GeneratedSources() string
GeneratedSources returns the parts of the build context that are generated rather than embedded, so image.Ref can hash them too — a change to the proxy's main or its go.mod must produce a new image tag like any other change.
func OriginalDestination ¶
OriginalDestination returns the address a redirected connection was originally aimed at, before iptables sent it here.
REDIRECT rewrites the destination, so by the time the proxy accepts the socket its own address is all that LocalAddr can report. The kernel keeps the original in a socket option, and SO_ORIGINAL_DST is the only way back to it.
It is used for the *port*, not the address: which port was asked for decides how to talk upstream (443 and 80 behave differently), while where to connect comes from resolving the name the client announced. Trusting the original address would defeat the point — the whole reason this package exists is that an address is not a name.
func SNIFromClientHello ¶
SNIFromClientHello extracts the server_name from a TLS ClientHello.
It returns ErrNoHostname when the handshake is well-formed but carries no SNI, which is a real case (a client connecting to a bare address) and is refused rather than resolved another way — see ErrNoHostname.
Types ¶
type Matcher ¶
type Matcher struct {
// contains filtered or unexported fields
}
Matcher decides whether a hostname is permitted.
Matching is exact and case-insensitive, with one explicit form of breadth: a pattern written `*.example.com` matches any single-or-multi label subdomain but **not** `example.com` itself. Everything else is literal.
Subdomains are deliberately not implied. Allowing `github.com` must not also allow `gist.github.com`, because that is the precise mistake the address-matching firewall made by accident — and `gist.github.com` is a write endpoint. Breadth has to be asked for.
func NewMatcher ¶
NewMatcher builds a Matcher from allowlist patterns. Empty and blank patterns are ignored rather than rejected: they come from merged config layers where a stray empty entry is a formatting accident, not a request.
type Server ¶
type Server struct {
Match *Matcher
// Resolve looks up a hostname. Injected so tests do not need DNS.
Resolve func(host string) ([]net.IP, error)
// Dial opens the upstream connection. Injected for the same reason.
Dial func(network, addr string) (net.Conn, error)
// Log receives one line per decision. Denials are the interesting half: a
// blocked connection previously left no trace the user could find.
Log func(Decision)
}
Server enforces the allowlist for connections redirected to it.