Skip to content

Commit 26b3f57

Browse files
committed
fix(tlsbridge): bound handshake + upstream-verify; guard empty-CA self-check; fail-fast keygen (review #522)
Address CodeRabbit findings on PR #522: - Terminator.Terminate now sets a 10s handshake deadline (cleared on success so it doesn't bound the kept-alive served conn). Nothing upstream bounded the client conn — sniffHost clears its read deadline and the CONNECT peek has none — so a stalled client could pin the serving goroutine indefinitely. - bridgeServe bounds the pre-forge HEAD reachability/cert probe with a 10s context timeout (on the probe ONLY — NOT the relay, which must stay unbounded for streaming responses; adding ResponseHeaderTimeout to the shared upstream client would reintroduce the streaming-MCP 502 the main client's comment warns against). CodeRabbit's 'Critical' on upstream.go was not a TLS-bypass — RootCAs/ MinVersion/no-skip-verify are correct; the real issue was an unbounded verify. - RunTrustSelfCheck returns early on empty CA PEM (strings.Contains(b,"") would otherwise log a false 'trust OK'). - NewMinter no longer discards the ecdsa.GenerateKey error (latent nil-deref in mint); panics at construction on the effectively-impossible failure. - Plan doc: correct the main-logging guidance (cmd entrypoint uses log.Fatalf, not slog+os.Exit). Deferred (per reviewer / Phase 2): bound SkipSet + per-host verify cache (LRU/TTL); NewFileSource CA IsCA/key-match validation (cert-manager path). Spec mitm-identifier drift is covered by the spec's existing terminology disclaimer. Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 0870f8c commit 26b3f57

5 files changed

Lines changed: 41 additions & 6 deletions

File tree

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer
4242
// are for.
4343
const streamReadIdleTimeout = 5 * time.Minute
4444

45+
// upstreamVerifyTimeout bounds the pre-forge HEAD reachability/cert probe in
46+
// bridgeServe. It applies to that probe only — never to the relay, which must
47+
// stay unbounded so streaming responses aren't cut off.
48+
const upstreamVerifyTimeout = 10 * time.Second
49+
4550
// Server is an HTTP forward proxy that performs token exchange on outbound requests.
4651
//
4752
// OutboundPipeline is a holder so the bound pipeline can be hot-swapped
@@ -451,8 +456,18 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge
451456
func (s *Server) bridgeServe(client net.Conn, authority, host string) bool {
452457
// 1) Verify upstream reachability + cert via the dedicated client, BEFORE forging.
453458
// HEAD avoids GET side-effects; a non-2xx status still returns err==nil (cert
454-
// verified), which is all we need. Only a transport/TLS error fails here.
455-
resp, err := s.TLSBridge.Upstream.Head("https://" + authority)
459+
// verified), which is all we need. Only a transport/TLS error fails here. The
460+
// verify is bounded by its own context timeout so a slow/stalled origin can't
461+
// pin the bridging goroutine — the timeout is on this probe ONLY, not on the
462+
// relay (which must stay unbounded for streaming responses).
463+
ctx, cancel := context.WithTimeout(context.Background(), upstreamVerifyTimeout)
464+
defer cancel()
465+
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "https://"+authority, nil)
466+
if err != nil {
467+
slog.Info("tls-bridge passthrough", "host", host, "reason", "upstream-verify", "error", err)
468+
return false
469+
}
470+
resp, err := s.TLSBridge.Upstream.Do(req)
456471
if err != nil {
457472
slog.Info("tls-bridge passthrough", "host", host, "reason", "upstream-verify", "error", err)
458473
return false // fall back to plain tunnel — agent's own e2e TLS still reaches origin

authbridge/authlib/tlsbridge/engine.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type Engine struct {
2424
// env is set, so this simply notes that egress will safely tunnel.
2525
func RunTrustSelfCheck(caPEM []byte) {
2626
want := strings.TrimSpace(string(caPEM))
27+
if want == "" {
28+
// An empty CA would make strings.Contains below always true → a false
29+
// "trust self-check OK". Guard so an empty/misconstructed CA is visible.
30+
slog.Warn("tls-bridge trust self-check skipped: empty CA PEM")
31+
return
32+
}
2733
for _, env := range []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"} {
2834
p := os.Getenv(env)
2935
if p == "" {

authbridge/authlib/tlsbridge/minter.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ func NewMinter(src CASource, o MinterOpts) *Minter {
4545
if o.LeafTTL <= 0 {
4646
o.LeafTTL = 24 * time.Hour
4747
}
48-
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
48+
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
49+
if err != nil {
50+
// P-256 keygen from crypto/rand effectively never fails; if it does, fail
51+
// fast at construction rather than nil-deref later in mint().
52+
panic(fmt.Errorf("tlsbridge: generate leaf key: %w", err))
53+
}
4954
return &Minter{
5055
src: src, max: o.CacheMax, ttl: o.LeafTTL, leafKey: key,
5156
ll: list.New(), items: make(map[string]*list.Element),

authbridge/authlib/tlsbridge/terminator.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ package tlsbridge
33
import (
44
"crypto/tls"
55
"net"
6+
"time"
67
)
78

9+
// handshakeTimeout bounds the server-side TLS handshake against the agent so a
10+
// stalled/malicious client cannot pin the serving goroutine indefinitely.
11+
const handshakeTimeout = 10 * time.Second
12+
813
// Terminator wraps a sniffed client conn as a tls.Server, using the Minter to
914
// forge a per-SNI leaf. ALPN offers h2 + http/1.1.
1015
type Terminator struct {
@@ -26,8 +31,12 @@ func (t *Terminator) Terminate(client net.Conn, host string) (*tls.Conn, error)
2631
},
2732
}
2833
conn := tls.Server(client, cfg)
34+
// Bound the handshake; clear the deadline on success so it does not leak
35+
// into the served-connection (keep-alive) lifetime.
36+
_ = conn.SetDeadline(time.Now().Add(handshakeTimeout))
2937
if err := conn.Handshake(); err != nil {
3038
return nil, err
3139
}
40+
_ = conn.SetDeadline(time.Time{})
3241
return conn, nil
3342
}

authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
- `(s *Server) HandleTransparentConn(clientConn net.Conn, dst string)` (`transparent.go:46`): `name, wrapped := sniffHost(clientConn); clientConn = wrapped` (`transparent.go:69-70`); local var `host` = `net.JoinHostPort(name, port)` or `dst` (`transparent.go:67-78`); dials `upstream` against `dst` with `defer upstream.Close()` (`transparent.go:115-120`); `tunnel(clientConn, upstream)` (`transparent.go:125`).
2626
- `sniffHost(conn net.Conn) (string, net.Conn)` (`sniff.go`): returns the recovered host string and a replayable `*peekedConn{net.Conn; r *bufio.Reader}` (`sniff.go:148-153`). It uses `br.Peek(...)` (non-consuming) internally but **discards the peeked bytes** and has **no exported peek method**. Task 6 adds `(c *peekedConn) Peek(n int) ([]byte, error)`.
2727
- `config.Config` (`config/config.go`): optional pointer blocks `MTLS *MTLSConfig \`yaml:"mtls,omitempty"\`` (`:33`), `SPIFFE *SPIFFEConfig \`yaml:"spiffe,omitempty"\`` (`:39`), each with a `Validate()` method (`:86`, `:133`) called from the top-level loader (`cfg.MTLS.Validate()` `:441`, `cfg.SPIFFE.Validate()` `:460`). The loader is **`func Load(path string) (*Config, error)`** (`:415`) — it takes a **file path, not bytes**. `config.go` already imports `fmt` and `os`.
28-
- main wiring (`cmd/authbridge-proxy/main.go`): `fpMTLS = &forwardproxy.MTLSOptions{...}` (`:243`); `fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS)` (`:256`); then `fpSrv.SkipHosts = …` (`:268`), `fpSrv.Shared = sharedStore` (`:272`); `transparentproxy.NewServer(fp.HandleTransparentConn)` (`:424`). main uses **`log/slog` + `os.Exit`** (imports `log/slog` `:17`, `os` `:20`) — **not** `log.Fatalf`.
28+
- main wiring (`cmd/authbridge-proxy/main.go`): `fpMTLS = &forwardproxy.MTLSOptions{...}` (`:243`); `fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS)` (`:256`); then `fpSrv.SkipHosts = …` (`:268`), `fpSrv.Shared = sharedStore` (`:272`); `transparentproxy.NewServer(fp.HandleTransparentConn)` (`:424`). NOTE: `cmd/authbridge-proxy/main.go` imports `"log"` and uses **`log.Fatalf`** for boot-fatal errors (no `os.Exit` in the boot path) — match that convention, not `slog`+`os.Exit`.
2929
- `pipeline`: `Action{Type ActionType, Violation *Violation}` with `Reject` const (`pipeline/action.go:11-24`); `SharedStore` is an interface (`pipeline/context.go:35`); `Context` (`pipeline/context.go:93`). The response phase (`RunResponse`/`RunResponseFrame`) lives inside the `handleRequest` body that Task 6 moves wholesale — no new response-phase code is authored.
3030

3131
---
@@ -1436,7 +1436,7 @@ func RunTrustSelfCheck(caPEM []byte) {
14361436
}
14371437
```
14381438

1439-
- [ ] **Step 6: Wire `main.go`** — beside the `fpMTLS` construction (`main.go:243`), build the engine when enabled; set the field after `NewServer` (mirroring `fpSrv.SkipHosts`/`fpSrv.Shared`). main uses `slog`+`os.Exit`, not `log.Fatalf`:
1439+
- [ ] **Step 6: Wire `main.go`** — beside the `fpMTLS` construction (`main.go:243`), build the engine when enabled; set the field after `NewServer` (mirroring `fpSrv.SkipHosts`/`fpSrv.Shared`). main uses `log.Fatalf` for boot-fatal errors — match it (the sketch below shows `slog.Error`+`os.Exit`; use `log.Fatalf` to fit the file):
14401440

14411441
```go
14421442
var bridge *tlsbridge.Engine
@@ -1528,6 +1528,6 @@ Phase 2 (operator) is intentionally **not** bite-sized here. When Phase 1 lands,
15281528
## Self-review notes
15291529

15301530
- **Spec coverage:** every Phase-1 spec item maps to a task — `tlsbridge/` units (T1–T5), serveOutbound + full-pipeline reuse (T6), reversible decision + upstream-verify-first (T7/T8 `bridgeServe`), auto-skip (T7/T9), interception scope gate (T3/T10), upstream client with injected roots (T4), h2 + keep-alive (T5), config + wiring + self-check (T10), no-broken-calls (T9). Operator items → Phase 2.
1531-
- **Fixes folded in from the end-to-end audit (2026-06-17):** `Load(path)` not `Load(bytes)` (T10 temp file); `slog`+`os.Exit` not `log.Fatalf` (T10); `(*peekedConn).Peek` added since `sniffHost` discards the ClientHello (T6) — replaces the undefined `peekFirstBytes`/`peek(5)`; `hostOnly`/`portOf`/`nameOrIP` helpers defined (T6/T7); CONNECT `peekedConn` constructed **with** its `bufio.Reader` (T8); `serveOutbound` selects `s.TLSBridge.Upstream` for bridge re-origination, never the mesh-mTLS `s.Client` (T6, **core correctness**); authority (`host:port`) carried so non-443 origins re-originate and verify on the right port (T7/T8); upstream verify uses `HEAD` to avoid `GET /` side-effects (T7); `FileSource` multi-format key parsing for 2c (T1); pre-dialed upstream closed and re-dialed only on fall-open (T7/T8).
1531+
- **Fixes folded in from the end-to-end audit (2026-06-17):** `Load(path)` not `Load(bytes)` (T10 temp file); main `log.Fatalf` for boot-fatal (T10 — corrected: the cmd entrypoint uses `log.Fatalf`, not `slog`+`os.Exit`); `(*peekedConn).Peek` added since `sniffHost` discards the ClientHello (T6) — replaces the undefined `peekFirstBytes`/`peek(5)`; `hostOnly`/`portOf`/`nameOrIP` helpers defined (T6/T7); CONNECT `peekedConn` constructed **with** its `bufio.Reader` (T8); `serveOutbound` selects `s.TLSBridge.Upstream` for bridge re-origination, never the mesh-mTLS `s.Client` (T6, **core correctness**); authority (`host:port`) carried so non-443 origins re-originate and verify on the right port (T7/T8); upstream verify uses `HEAD` to avoid `GET /` side-effects (T7); `FileSource` multi-format key parsing for 2c (T1); pre-dialed upstream closed and re-dialed only on fall-open (T7/T8).
15321532
- **Known minor limitations (documented, not bugs):** CONNECT-path `scope=external` keys on a hostname (no IP) so it won't classify a hostname-addressed in-cluster service as internal — the transparent path (which has the SO_ORIGINAL_DST IP) does; the bridge pays one upstream HEAD per agent connection (reversibility cost) plus the relay handshake.
15331533
- **Type consistency:** `GetCertificateForHost(host string)`, `Terminate(client net.Conn, host string)`, `Classify(host, ip string, port int, first []byte) (Verdict, string)`, `Engine{Decision,Term,Skip,Upstream,CAPEM}`, `bridgeServe(client net.Conn, authority, host string) bool`, `serveOutbound(w, r, isBridge bool)` are used consistently across tasks.

0 commit comments

Comments
 (0)