Skip to content

Egress: support validated raw-TCP outbound (databases, message brokers) — not just HTTP/HTTPS #337

Description

@initializ-mk

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_hostsDomainMatcher (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:

  1. Client-facing protocol — non-HTTP clients have no way to reach that tunnel (they don't speak HTTP CONNECT and ignore proxy env).
  2. Port granularity — the allowlist matches hostname only; TCP policy needs host:port.
  3. 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)

  1. 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.
  2. 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.
  3. 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).
  4. 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

  • An agent tool can open a validated raw-TCP connection to an allow-listed host:port (e.g. psqldb.internal:5432).
  • A raw-TCP connection to a non-allow-listed host:port is denied and audited (not silently direct).
  • Deny-all remains the default; HTTP/HTTPS egress behavior is unchanged.
  • Docs (docs/security/egress-control.md) cover the TCP allowlist, the client env/proxychains setup, and the allow_private_ips requirement 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestforge-coreAffects the forge-core library (runtime, security, types, llm, mcp, auth)securitySecurity vulnerability fixes

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions