egressproxy

package
v0.0.0-...-a271580 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: MIT Imports: 14 Imported by: 0

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

View Source
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

View Source
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

func OriginalDestination(c net.Conn) (net.IP, int, error)

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

func SNIFromClientHello(b []byte) (string, error)

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.

func SourceOf

func SourceOf(name string) (string, error)

SourceOf returns one embedded file, for tests.

func WriteBuildContext

func WriteBuildContext(dir string, write func(name string, data []byte) error) error

WriteBuildContext lays the proxy source out under dir, ready for a docker builder stage to compile.

Types

type Decision

type Decision struct {
	Host    string
	Port    int
	Allowed bool
	Reason  string
}

Decision is the outcome for one connection, for logging and for the caller.

func (Decision) String

func (d Decision) String() string

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

func NewMatcher(patterns []string) *Matcher

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.

func (*Matcher) Allows

func (m *Matcher) Allows(host string) bool

Allows reports whether host is permitted.

func (*Matcher) Len

func (m *Matcher) Len() int

Len reports how many patterns the matcher holds, for the startup log.

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.

func New

func New(m *Matcher, log func(Decision)) *Server

New returns a Server with real networking wired in.

func (*Server) Serve

func (s *Server) Serve(l net.Listener) error

Serve accepts connections until the listener is closed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL