Skip to content

Commit 82ded4c

Browse files
committed
feat(tlsbridge): bound the runtime SkipSet (TTL + size cap); drop residual MITM comment
- SkipSet was an unbounded map. With scope/CIDR removed, all eligible egress flows through the bridge, so a flood of distinct SNIs each rejecting the forged leaf could grow it without bound. Add a 10m TTL (self-healing: a transiently-pinned/rotated client gets re-attempted) and a 4096 cap with oldest-expiry eviction on the cold Add path. Contains stays an RLock read (expired entries read as absent; Add reclaims them). Tests cover TTL expiry and the size bound. - main.go: drop a residual '(MITM)' in a comment (rename completed elsewhere). Defers (per reviewer): caching upstream-verify success per host is a pure optimization that trades verify-freshness for fewer probes — left for Phase 2. Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 4acd038 commit 82ded4c

3 files changed

Lines changed: 75 additions & 9 deletions

File tree

authbridge/authlib/tlsbridge/decision.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tlsbridge
22

33
import (
44
"sync"
5+
"time"
56
)
67

78
type Verdict int
@@ -66,21 +67,59 @@ func looksLikeTLSRecord(b []byte) bool {
6667
return b[0] == 0x16 && b[1] == 0x03 && b[2] >= 0x01 && b[2] <= 0x04
6768
}
6869

70+
const (
71+
// skipTTL bounds how long an auto-skipped host stays skipped before the
72+
// bridge re-attempts interception (self-healing: a transiently-pinned or
73+
// rotated client gets another chance). skipMax caps the set so a flood of
74+
// distinct SNIs (each rejecting the forged leaf) cannot grow it unbounded —
75+
// since scope removal routes ALL eligible egress through here.
76+
skipTTL = 10 * time.Minute
77+
skipMax = 4096
78+
)
79+
6980
// SkipSet is the runtime auto-skip set (hosts whose minted leaf the client
70-
// rejected). Concurrent-safe; augments the static skip list.
81+
// rejected). Concurrent-safe; augments the static skip list. Entries expire
82+
// after skipTTL and the set is bounded to skipMax (oldest-expiry eviction).
7183
type SkipSet struct {
72-
mu sync.RWMutex
73-
m map[string]bool
84+
mu sync.RWMutex
85+
ttl time.Duration
86+
max int
87+
m map[string]time.Time // host -> expiry
88+
}
89+
90+
func NewSkipSet() *SkipSet {
91+
return &SkipSet{ttl: skipTTL, max: skipMax, m: map[string]time.Time{}}
7492
}
7593

76-
func NewSkipSet() *SkipSet { return &SkipSet{m: map[string]bool{}} }
7794
func (s *SkipSet) Add(host string) {
7895
s.mu.Lock()
79-
s.m[host] = true
80-
s.mu.Unlock()
96+
defer s.mu.Unlock()
97+
now := time.Now()
98+
if len(s.m) >= s.max {
99+
// Purge expired entries; if still full, drop the earliest-expiring one.
100+
// Add is cold (only fires when a minted leaf is rejected), so an O(n)
101+
// sweep here is cheap.
102+
var oldestK string
103+
var oldestT time.Time
104+
for k, exp := range s.m {
105+
if !exp.After(now) {
106+
delete(s.m, k)
107+
continue
108+
}
109+
if oldestK == "" || exp.Before(oldestT) {
110+
oldestK, oldestT = k, exp
111+
}
112+
}
113+
if len(s.m) >= s.max && oldestK != "" {
114+
delete(s.m, oldestK)
115+
}
116+
}
117+
s.m[host] = now.Add(s.ttl)
81118
}
119+
82120
func (s *SkipSet) Contains(host string) bool {
83121
s.mu.RLock()
84122
defer s.mu.RUnlock()
85-
return s.m[host]
123+
exp, ok := s.m[host]
124+
return ok && time.Now().Before(exp) // expired entries read as absent; Add reclaims them
86125
}

authbridge/authlib/tlsbridge/decision_test.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package tlsbridge
22

3-
import "testing"
3+
import (
4+
"testing"
5+
"time"
6+
)
47

58
func TestDecision_Classify(t *testing.T) {
69
d := NewDecision(DecisionOpts{
@@ -55,6 +58,30 @@ func TestSkipSet_AutoSkip(t *testing.T) {
5558
}
5659
}
5760

61+
func TestSkipSet_TTLExpires(t *testing.T) {
62+
s := NewSkipSet()
63+
s.ttl = 20 * time.Millisecond // same-package test can tighten the TTL
64+
s.Add("h")
65+
if !s.Contains("h") {
66+
t.Fatal("should contain immediately after Add")
67+
}
68+
time.Sleep(40 * time.Millisecond)
69+
if s.Contains("h") {
70+
t.Error("entry should have expired (self-healing re-attempt)")
71+
}
72+
}
73+
74+
func TestSkipSet_Bounded(t *testing.T) {
75+
s := NewSkipSet()
76+
s.max = 2 // cap small; a flood of distinct SNIs must not grow it unbounded
77+
s.Add("a")
78+
s.Add("b")
79+
s.Add("c")
80+
if len(s.m) > 2 {
81+
t.Errorf("SkipSet grew past max: len=%d, want <=2", len(s.m))
82+
}
83+
}
84+
5885
func TestDecision_HandlesPort(t *testing.T) {
5986
// Default set when Ports is nil.
6087
d := NewDecision(DecisionOpts{})

authbridge/cmd/authbridge-proxy/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ func main() {
249249
}
250250

251251
// TLS bridge: when enabled, the forward proxy terminates agent outbound
252-
// TLS (MITM) so the outbound pipeline sees decrypted HTTPS. Constructed
252+
// TLS so the outbound pipeline sees decrypted HTTPS. Constructed
253253
// here and set on fpSrv below (mirroring fpSrv.SkipHosts / fpSrv.Shared).
254254
// A nil *Engine leaves today's blind-tunnel behavior intact.
255255
var bridge *tlsbridge.Engine

0 commit comments

Comments
 (0)