Skip to content

feat(security): SOCKS5 raw-TCP egress with port-aware allowlist — closes #337 (PR 2/2)#355

Open
naveen-kurra wants to merge 2 commits into
mainfrom
fix/egress-socks5-tcp
Open

feat(security): SOCKS5 raw-TCP egress with port-aware allowlist — closes #337 (PR 2/2)#355
naveen-kurra wants to merge 2 commits into
mainfrom
fix/egress-socks5-tcp

Conversation

@naveen-kurra

@naveen-kurra naveen-kurra commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Merge order

# PR Merge order
1 #348 (fix/egress-private-cidrs) Merge first
2 This PR (fix/egress-socks5-tcp) Rebase on merged #348, then merge

What lands

Config additions:

egress:
  mode: allowlist
  allowed_hosts: [api.stripe.com]           # HTTP/HTTPS — unchanged
  allowed_tcp:                              # NEW: raw-TCP allowlist
    - db.internal:5432
    - "*.brokers.internal:9092"             # wildcard host, exact port
    - metrics.internal:*                    # exact host, any port
  allowed_private_cidrs: [10.20.0.0/16]     # from PR #348 — required for internal targets

Code:

File What's there
forge-core/security/tcp_matcher.go NEW — port-aware matcher (host:port, *.suffix:port, host:*, *.suffix:*); validates at Resolve() time
forge-core/security/socks5.go NEW — RFC 1928 CONNECT-only server, ~180 LOC, no external dep
forge-core/security/dial_gate.go NEW — ValidateAndDial primitive shared by handleConnect + handleSOCKS5
forge-core/security/egress_proxy.go Second listener bound when allowed_tcp non-empty; SOCKSURL() returns socks5h://…
forge-cli/tools/exec.go Injects ALL_PROXY/SOCKS_PROXY env vars alongside HTTP_PROXY
forge-cli/runtime/runner.go Wires TCPMatcher into proxy; threads socksURL through skill executor + RunSkillScriptTool
docs/security/egress-control.md Full raw-TCP section: config, client matrix (curl/Go/Python/Node/CLI-via-proxychains), platform caveats, fail-closed invariants

Fail-closed invariants (from the ticket, verified by tests)

  • Deny-all default. Raw TCP is denied unless explicitly allow-listed. Non-allow-listed targets get a SOCKS5 denial before dial + an audit event.
  • Port granularity. db.internal:5432 permits only port 5432; :5433 is denied.
  • Localhost bypass. Preserved for both paths.
  • socks5h:// scheme. Forces server-side hostname resolution — clients that pre-resolve get the IP path in SafeDialer.
  • BIND / UDP ASSOCIATE rejected with REP=0x07 (command not supported).
  • HTTP audit shape unchanged. Downstream consumers keying by hostname keep working; SOCKS5 audits carry host:port because port is the point.
  • Listener not bound when allowed_tcp empty. Zero-config deploys see no extra port + no ALL_PROXY.

Test plan

  • go test ./forge-core/... — all pass (including new TCPMatcher + SOCKS5 suites)
  • go test ./forge-cli/... — all pass (including regression on browser tools' HTTP audit shape)
  • go test ./forge-plugins/... — all pass
  • golangci-lint run ./forge-core/... ./forge-cli/... ./forge-plugins/... — 0 issues
  • gofmt -l clean

New tests:

  • TCPMatcher: exact host:port, wildcard host + exact port, host + any port, wildcard host + any port, case-insensitive host, invalid entries rejected at load, nil matcher fail-closed
  • SOCKS5: allowed target echo round-trip, denied-not-in-allowlist policy denial, HTTP allowlist also covers TCP (single-source-of-truth), BIND rejected, malformed greeting doesn't wedge, listener absent when no TCP matcher, empty matcher disables listener, SOCKSURL uses socks5h://
  • Resolver: allowed_tcp validated at load, invalid host:port fails Resolve

Known limitations (documented)

  • SOCKS5 audit has no task attribution. SOCKS5v5 no-auth has no channel for per-request creds; HTTP path retains Egress: subprocess proxy audit events missing task_id / correlation_id #338 attribution via Proxy-Authorization. Adding SOCKS5-auth attribution is a follow-up when there's a concrete need.
  • proxychains-ng caveats. Linux glibc/musl works. macOS mostly doesn't (SIP + DYLD). Static Go binaries and setuid contexts don't. Language-native SOCKS5 SDKs are the primary UX for CLIs.
  • UDP unsupported. SOCKS5 UDP ASSOCIATE explicitly rejected — TCP-only for MVP. Kafka QUIC brokers or DNS-over-UDP would want this later.

Closes: #337

Realtime end-to-end test — 19 angles across 4 dimensions

Beyond the unit test suite: booted the real EgressProxy in-process with two live listeners (HTTP + SOCKS5), real TCP + HTTP echo servers, real curl subprocesses, and a Go client speaking the SOCKS5 wire byte-for-byte. All 19 angles passed.

# Angle Verdict
1 Race + 10× stress on new tests (-race -count=10 -run "SOCKS5|TCPMatcher|PrivateCIDR") ✓ 20.8s, 0 races
2 100 parallel SOCKS5 sessions with per-session payloads (cross-talk detection) ✓ under -race
3 50 dial-then-close cycles (relay-goroutine leak detection)
4 HTTP proxy — allowed_hosts target succeeds (regression)
5 HTTP proxy — non-allowed host denied
6 SOCKS5 — localhost bypass raw-TCP echo ✓ real bytes flow
7 SOCKS5 — non-allow-listed host:port denied
8 SOCKS5 — BIND command rejected with REP=0x07 ✓ wire-level verified
9 SOCKS5 — UDP ASSOCIATE rejected
10 SOCKS5 — unsupported ATYP rejected with REP=0x08
11 Config — invalid host:port fails Resolve
12 Config — invalid CIDR fails ParsePrivateCIDRs
13 Empty allowed_tcp — SOCKS5 listener not bound
14 SOCKSURL scheme is socks5h:// (server-side DNS)
15 Cloud metadata IP stays blocked despite CIDR list
16 Audit — SOCKS5 event Domain is host:port
17 Audit — HTTP CONNECT event Domain hostname-only (back-compat)
18 HTTP CONNECT — Proxy-Authorization creds surface as TaskID (#338 regression)
19 SkillCommandExecutor env injection: (a) SOCKSURL set → ALL_PROXY / all_proxy / SOCKS_PROXY all set; (b) SOCKSURL empty → no leak; (c) HTTP_PROXY unaffected ✓ 3/3

Cross-module -race sweep clean on forge-core/security/…, forge-cli/tools/…, forge-cli/runtime/ — no data races, no goroutine leaks, no test flake across repeated runs.

Not testable locally (documented):

  • proxychains-ng behavior on macOS (SIP blocks DYLD_INSERT_LIBRARIES) — noted in the docs client-support matrix
  • Real Postgres/Redis/Kafka client integration — would need those binaries + servers. The wire behavior they'd trigger (DOMAIN ATYP → validated dial → SafeDialer → relay) is proven by angles 6 and 16.

#337)

Adds `egress.allowed_tcp` for raw-TCP egress (databases, message brokers)
governed by the same allowlist engine that gates HTTP/HTTPS. The proxy now
binds a second listener speaking SOCKS5v5 alongside the HTTP forward proxy —
one policy, one audit shape, two client protocols. Skill subprocesses reach
it via ALL_PROXY / SOCKS_PROXY env vars.

Why the enterprise segment needs this: `libpq`/`redis-cli`/`kafka-*` etc.
open raw sockets — they ignore HTTP_PROXY entirely, so today they either
bypass egress (in dev) or fail closed (in locked deploys). Neither is
usable. Layered on top of PR 1's `allowed_private_cidrs`, this lets an
agent reach exactly `db.internal:5432` on `10.20.0.0/16` and nothing else.

Key pieces:
- forge-core/security/tcp_matcher.go — new port-aware matcher. Entries:
  `host:port`, `*.suffix:port`, `host:*`, `*.suffix:*`. Same case-insensitive
  wildcard-suffix rules as DomainMatcher. Invalid entries fail at Resolve()
  time, not at first dial.
- forge-core/security/socks5.go — minimal RFC 1928 CONNECT-only server (~180
  LOC, no external dep). DOMAIN/IPv4/IPv6 ATYP. BIND + UDP ASSOCIATE
  rejected with REP=0x07 (small explicit surface).
- forge-core/security/dial_gate.go — new `ValidateAndDial` primitive shared
  by handleConnect and handleSOCKS5. One codepath for policy + audit + dial,
  no drift possible.
- forge-core/security/egress_proxy.go — binds a second listener when
  `allowed_tcp` is non-empty (skips it otherwise — no extra port for
  deployments that don't need raw-TCP). `SOCKSURL()` returns the
  `socks5h://…` URL; the `h` forces server-side hostname resolution so the
  proxy can run the allowlist check against the hostname the client
  intended, not an IP it pre-resolved.
- forge-cli/tools/exec.go — injects ALL_PROXY / SOCKS_PROXY (lowercase +
  uppercase) into skill subprocess env when SOCKSURL is set. Same lifecycle
  as HTTP_PROXY.
- forge-cli/runtime/runner.go — wires the TCP matcher into the proxy and
  threads socksURL through registerSkillTools → SkillCommandExecutor and
  through RunSkillScriptTool.

Backward compatibility:
- HTTP audit shape unchanged (hostname-only Domain field) — the SOCKS5 path
  records `host:port` since port is the point. Callers key by hostname keep
  working; SOCKS5 event consumers get the granularity they need.
- Existing tests untouched; new tests cover TCPMatcher (exact/wildcard/any-
  port), SOCKS5 end-to-end (echo round-trip, denied-not-in-allowlist,
  HTTP-allowlist-also-covers-TCP, BIND-rejected, malformed-greeting,
  listener-absent-without-config, empty-matcher-disables-listener,
  socks5h-scheme).

Limitations documented in docs/security/egress-control.md:
- SOCKS5v5 (no-auth) has no channel for per-request task attribution —
  raw-TCP audit events carry host:port + decision only, no task/correlation
  IDs. HTTP path retains #338 attribution via Proxy-Authorization.
- `proxychains-ng` (for CLIs that don't speak SOCKS5 natively) works on
  Linux glibc/musl; macOS is mostly out (SIP + DYLD), static Go binaries
  and setuid contexts are out.

Depends on: PR #348 (allowed_private_cidrs — the SafeDialer signature +
`AllowedPrivateCIDRs` on EgressConfig this PR builds on).
Closes: #337
Drives 100 parallel echo round-trips through one SOCKS5 proxy with distinct
per-session payloads to catch cross-session byte leakage or handshake race
conditions. Second test opens 50 sessions and immediately closes each to
prove the relay goroutines exit cleanly instead of leaking on client-side
close. Both are skipped under -short; the real signal is running them
with -race.

Ran green with `go test -race -count=10` on the whole security package.
@naveen-kurra
naveen-kurra force-pushed the fix/egress-socks5-tcp branch from faf15e7 to 004a88a Compare July 21, 2026 20:24
@naveen-kurra

Copy link
Copy Markdown
Collaborator Author

Rebased onto the new main after #348 landed. Only one file conflicted — forge-cli/build/egress_stage.go — because #348 extended the build-time security.Resolve call to validate allowed_private_cidrs, and this PR extends the same call to validate allowed_tcp. Resolution combines both: build-time validation now covers both lists, keeping the fail-loud posture symmetric between forge build and forge run.

Post-rebase:

  • go build clean across all three modules
  • go test -count=1 -short ./... green (forge-core + forge-cli + forge-plugins)
  • golangci-lint run ./forge-core/... ./forge-cli/... ./forge-plugins/... — 0 issues
  • Docs merged cleanly (both PRs added sections to docs/security/egress-control.md but in non-overlapping regions)

Ready for review pass 2.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: SOCKS5 raw-TCP egress with port-aware allowlist (PR 2/2 for #337)

A new no-auth SOCKS5 listener is a serious attack surface, so I read it adversarially — bind address, dial chokepoint, localhost bypass, wildcard matching, wire parser. This is genuinely well-built. No High/Medium security issues — a few Low/doc items and one accepted limitation to confirm. Merge-ready pending the #348 rebase (the PR currently shows CONFLICTING because it carries the #348 commit).

What I verified holds (the things that could have gone badly wrong)

  • Both listeners bind 127.0.0.1:0 — loopback-only. The SOCKS5 proxy is not an open proxy; only in-pod processes reach it. For a no-auth SOCKS5 server this is the single most important property, and it's correct.
  • ValidateAndDial is a real shared chokepoint. HTTP CONNECT and SOCKS5 both route through it; the SOCKS5 handler has no other dial path. Every non-localhost dial goes through SafeDialer (SSRF + private-CIDR + always-blocked-metadata + strict-IP) after the allowlist — matcher is policy, SafeDialer is IP-safety, layered.
  • IsLocalhost is exact (== "localhost", strict-IPv4 loopback, IPv6 loopback), so localhost.evil.com / 127.0.0.1.evil.com are NOT treated as localhost — the bypass can't be tricked into a DNS-resolved external dial, and octal/hex are guarded by ParseStrictIPv4. The bypass grants no new capability anyway (in-pod processes reach loopback directly).
  • TCP matcher wildcard keeps the leading dot (.brokers.internal), so evilbrokers.internal can't slip past *.brokers.internal. Entries validated at Resolve() load time; nil matcher fails closed.
  • SOCKS5 parser is robust: bounded reads (domain ≤255, no unbounded alloc), 15s handshake deadline vs slow-loris (cleared before the long-lived relay), CONNECT-only, BIND/UDP rejected with REP=0x07, unsupported ATYP 0x08.
  • socks5h:// forces server-side DNS so the allowlist runs on the intended hostname, not a pre-resolved IP — closes the pre-resolve laundering gap.
  • Wiring is correct where mounted: SetTCPMatcher before Start; socksURL threaded to skill executors; env injection gated on non-empty SOCKSURL (no leak when off), on the same minimal-env model that keeps secrets out of subprocesses.
  • Excellent test depth, incl. -race stress (100 parallel sessions, goroutine-leak detection). CI fully green.

Findings — all Low / doc (see inline)

  1. Low (functionality + doc mismatch): IPv6-literal targets in allowed_tcp don't work as documented — the bracketed [::1]:5432 form is stored with brackets but the SOCKS5 IPv6 ATYP yields ::1 (no brackets), so it never matches. Fail-closed (safe), but the doc example is non-functional.
  2. Low (awareness/doc): the HTTP allowed_hosts allowlist authorizes raw-TCP on any port to that host (matches pre-existing CONNECT behavior, but worth an explicit callout now that SOCKS5 makes it raw-TCP).
  3. Nit: the localhost dial isn't ctx-aware (uses net.DialTimeout while the non-localhost path threads ctx into SafeDialContext).

Accepted limitation worth confirming

  • SOCKS5 audit has no task attribution (no-auth has no per-request credential channel) — documented and reasonable for the MVP, but since the audit story is #337's selling point, unattributed raw-TCP egress is a real observability gap. Just confirm the SOCKS5-auth-attribution follow-up is tracked; enterprises will eventually ask "which task reached the database?"

Great contribution — the shared-primitive discipline (ValidateAndDial, no two-codepath drift), loopback-only binding, and the leading-dot wildcard are exactly the details that make a SOCKS5 gate safe rather than a footgun.

}
// SplitN on the LAST colon so IPv6 literals in brackets could be supported
// later without a rewrite. For now, IPv6 must be bracketed: `[::1]:5432`.
i := strings.LastIndex(raw, ":")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (functionality + doc mismatch). The comment above says IPv6 must be bracketed ([::1]:5432), but bracketed entries never match at runtime: LastIndex(":") on [::1]:5432 stores host [::1] (with brackets), while the SOCKS5 IPv6 ATYP path builds the host via net.IP(buf).String()::1 (no brackets), so IsAllowed's exact/suffix compare can't hit. (Unbracketed IPv6 like ::1:5432 happens to match by luck of the last-colon split, but a general 2001:db8::1:5432 is ambiguous.) Net: IPv6-literal targets are effectively unsupported and the documented [::1]:5432 example is non-functional. It's fail-closed, so not a security hole — but either normalize brackets on both sides (strip on parse, or net.SplitHostPort which handles [..]), or document IPv6 literals as unsupported for now so the doc doesn't promise a form that silently denies.

return nil, fmt.Errorf("egress: %w", err)
}

allowed := p.matcher.IsAllowed(host) || (p.tcpMatcher != nil && p.tcpMatcher.IsAllowed(host, port))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (awareness/doc). p.matcher.IsAllowed(host) is the port-agnostic HTTP allowlist, so a host in allowed_hosts is reachable over SOCKS5 on any port — listing api.stripe.com for HTTPS also grants arbitrary-protocol raw TCP to api.stripe.com:22, :3389, etc. This matches the pre-existing CONNECT behavior (CONNECT already checked hostname-only), so it's not newly introduced — SafeDialer still bounds it to non-internal IPs. But once raw-TCP egress is on, operators may not expect allowed_hosts to be raw-TCP-any-port. Worth an explicit line in the egress doc so allowed_hosts isn't read as HTTPS-only. (Also minor, L81-83: the localhost branch dials via net.DialTimeout, ignoring ctx — the non-localhost path threads ctx into SafeDialContext; thread it here too for cancellation symmetry.)

// SOCKS5 has no per-request context — the handshake owns the conn.
// Use context.Background so cancellation from server shutdown flows
// through via listener close (relayPair exits when either side closes).
upstream, dialErr := p.ValidateAndDial(context.Background(), host, port)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted-limitation confirm (not blocking): because SOCKS5v5 no-auth has no per-request credential channel, this dial carries context.Background() and no identity — so raw-TCP audit events have host:port + decision but no task/correlation ID, unlike the HTTP CONNECT path (which recovers identity from Proxy-Authorization, #338). Reasonable for the MVP and documented, but since per-task attribution of egress is #337's whole value prop, an unattributed raw-TCP event ("something reached db.internal:5432") is a weaker audit signal than the HTTP path. Please make sure the SOCKS5-auth-attribution follow-up is filed as a tracked issue rather than only a docs note — enterprises will ask "which task opened that DB connection?"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants