Problem
Forge's egress control only governs HTTP/HTTPS. A tool binary that opens a raw TCP connection — psql/libpq → 5432, mysql → 3306, redis-cli → 6379, mongosh → 27017, Kafka → 9092, amqp/RabbitMQ → 5672, NATS → 4222, MQTT → 1883 — cannot make a validated outbound connection. So database and messaging CLIs/SDKs run under an agent either fail (locked-down deploys) or bypass the allowlist entirely (a control gap).
Why — the current mechanism
Grounded in the code:
- The egress proxy is an HTTP forward proxy (
forge-core/security/egress_proxy.go): it serves plain HTTP (handleHTTP) and HTTPS via CONNECT tunneling (handleConnect).
- Enforcement is application-level env injection: subprocesses get
HTTP_PROXY/HTTPS_PROXY/http_proxy/https_proxy = the proxy URL (forge-cli/tools/exec.go:147-153). HTTP clients honor these and route through the proxy; the proxy validates the host against the allowlist.
- Raw-TCP clients do not honor
HTTP_PROXY/HTTPS_PROXY — those are an HTTP-only convention. A Postgres/Redis/Kafka client opens a direct socket to host:port, so it never traverses the proxy and is never checked by DomainMatcher.
- The allowlist is hostname-only:
egress.allowed_hosts → DomainMatcher (domain_matcher.go) matches exact + .suffix wildcards, no port. There's no way to even express db.internal:5432.
SafeDialer blocks RFC-1918 / loopback by default unless allow_private_ips: true (safe_dialer.go) — so internal DBs on private subnets are blocked even if a TCP path existed.
Key insight (makes this cheaper than it looks)
handleConnect already hijacks the connection and pipes raw bytes through SafeDialer to req.Host (host:port). So the enforcement engine can already gate arbitrary TCP — the CONNECT tunnel is transport-agnostic. The gaps are only:
- Client-facing protocol — non-HTTP clients have no way to reach that tunnel (they don't speak HTTP
CONNECT and ignore proxy env).
- Port granularity — the allowlist matches hostname only; TCP policy needs
host:port.
- Private destinations — internal brokers/DBs need
allow_private_ips (or a private-CIDR allowlist) to be reachable at all.
Proposal (options, roughly in order of preference)
- SOCKS5 egress proxy alongside the HTTP proxy. SOCKS5 carries
host:port and works for arbitrary TCP; reuse the existing SafeDialer + a port-aware matcher to validate the target, reuse the same deny-all default + OnAttempt audit hook. Clients opt in via ALL_PROXY/SOCKS_PROXY env (injected next to the HTTP proxy vars). This is the natural extension — same validate-then-pipe model the CONNECT path already uses.
- Port-aware TCP allowlist — extend egress config with
host:port entries, e.g.:
egress:
mode: allowlist
allowed_hosts: [api.stripe.com] # HTTP(S), unchanged
allowed_tcp: [db.internal:5432, redis.internal:6379, broker:9092]
allow_private_ips: true # internal destinations
Needed regardless of the transport mechanism; the matcher gains an optional port dimension.
proxychains-ng (LD_PRELOAD) for exec'd bins that ignore ALL_PROXY — force libc connect() through the SOCKS proxy without the client's cooperation. Pairs with (1); handles stubborn dynamically-linked clients (many DB CLIs).
- Transparent redirect (iptables REDIRECT / netns) as the platform-managed heavyweight option — captures all outbound TCP regardless of client proxy support; needs
NET_ADMIN, so likely a managed-mode concern, not the standalone default.
Security invariants to preserve
- Deny-all default — raw TCP is denied unless explicitly allow-listed (
allowed_tcp), same posture as HTTP today.
- Validated, not bypassed — every allowed TCP connection is checked against the allowlist +
SafeDialer (no SSRF to blocked IPs); the audit hook fires per attempt (allow/deny), mirroring HTTP egress.
- Port-scoped — allow
db.internal:5432, not all ports on the host.
- Keep private-IP blocking opt-in; document that internal DB/broker access requires
allow_private_ips or a private-CIDR allowlist.
Acceptance
Related
forge-core/security/egress_proxy.go (CONNECT tunnel to reuse) · domain_matcher.go (host-only matcher to extend) · safe_dialer.go (SSRF/private-IP guard) · forge-cli/tools/exec.go (proxy env injection) · docs/security/egress-control.md.
Problem
Forge's egress control only governs HTTP/HTTPS. A tool binary that opens a raw TCP connection —
psql/libpq→ 5432,mysql→ 3306,redis-cli→ 6379,mongosh→ 27017, Kafka → 9092,amqp/RabbitMQ → 5672, NATS → 4222, MQTT → 1883 — cannot make a validated outbound connection. So database and messaging CLIs/SDKs run under an agent either fail (locked-down deploys) or bypass the allowlist entirely (a control gap).Why — the current mechanism
Grounded in the code:
forge-core/security/egress_proxy.go): it serves plain HTTP (handleHTTP) and HTTPS viaCONNECTtunneling (handleConnect).HTTP_PROXY/HTTPS_PROXY/http_proxy/https_proxy= the proxy URL (forge-cli/tools/exec.go:147-153). HTTP clients honor these and route through the proxy; the proxy validates the host against the allowlist.HTTP_PROXY/HTTPS_PROXY— those are an HTTP-only convention. A Postgres/Redis/Kafka client opens a direct socket tohost:port, so it never traverses the proxy and is never checked byDomainMatcher.egress.allowed_hosts→DomainMatcher(domain_matcher.go) matches exact +.suffixwildcards, no port. There's no way to even expressdb.internal:5432.SafeDialerblocks RFC-1918 / loopback by default unlessallow_private_ips: true(safe_dialer.go) — so internal DBs on private subnets are blocked even if a TCP path existed.Key insight (makes this cheaper than it looks)
handleConnectalready hijacks the connection and pipes raw bytes throughSafeDialertoreq.Host(host:port). So the enforcement engine can already gate arbitrary TCP — the CONNECT tunnel is transport-agnostic. The gaps are only:CONNECTand ignore proxy env).host:port.allow_private_ips(or a private-CIDR allowlist) to be reachable at all.Proposal (options, roughly in order of preference)
host:portand works for arbitrary TCP; reuse the existingSafeDialer+ a port-aware matcher to validate the target, reuse the same deny-all default +OnAttemptaudit hook. Clients opt in viaALL_PROXY/SOCKS_PROXYenv (injected next to the HTTP proxy vars). This is the natural extension — same validate-then-pipe model the CONNECT path already uses.host:portentries, e.g.:proxychains-ng(LD_PRELOAD) for exec'd bins that ignoreALL_PROXY— force libcconnect()through the SOCKS proxy without the client's cooperation. Pairs with (1); handles stubborn dynamically-linked clients (many DB CLIs).NET_ADMIN, so likely a managed-mode concern, not the standalone default.Security invariants to preserve
allowed_tcp), same posture as HTTP today.SafeDialer(no SSRF to blocked IPs); the audit hook fires per attempt (allow/deny), mirroring HTTP egress.db.internal:5432, not all ports on the host.allow_private_ipsor a private-CIDR allowlist.Acceptance
host:port(e.g.psql→db.internal:5432).host:portis denied and audited (not silently direct).docs/security/egress-control.md) cover the TCP allowlist, the client env/proxychains setup, and theallow_private_ipsrequirement for internal destinations.Related
forge-core/security/egress_proxy.go(CONNECT tunnel to reuse) ·domain_matcher.go(host-only matcher to extend) ·safe_dialer.go(SSRF/private-IP guard) ·forge-cli/tools/exec.go(proxy env injection) ·docs/security/egress-control.md.