Skip to content

Commit e8e632d

Browse files
committed
fix(authbridge): Address PR #500 review feedback
Adds construction-time guards, audit logging, and tests asked for in the PR #500 review. Skiphost matcher (authlib/listener/skiphost): - Reject match-all patterns ("*", "**", whitespace-only) at New() so a single misconfigured entry cannot silently disable the entire outbound enforcement pipeline. Mirrors the bypass-pattern guard added to ibac in #496. Empirically verified: under .-delimited glob "*" matches every single-label hostname (which is how every short Kubernetes service name reaches the listener), and "**" is the unambiguous match-all. Patterns like "*.svc.cluster.local" or "*.*" are NOT match-all and remain accepted. - Reject patterns containing ":" so an operator typo like "otel-collector:8335" fails fast at startup rather than silently never matching (Match strips the port from the incoming host before comparing, so colon-bearing patterns would never fire). - Add MatchPattern(host) (string, bool) so listeners can attribute audit logs / counters to the specific raw operator-supplied pattern that fired. Match() is now a thin wrapper around it for callers that only want the boolean. Listener skip-path audit logging: - Each successful skip in extproc (handleOutbound, handleOutboundBody) and forwardproxy (handleRequest, handleConnect) now emits a structured slog INFO line carrying host, matched pattern, and method/path. Without this trail, a successful self-exemption left no trace anywhere -- no session event, no plugin invocation -- because that is exactly what skip means. Trust-model documentation (authlib/config/config.go): - ListenerConfig.SkipHosts godoc now spells out that the matched value is agent-influenceable on the ext_proc and HTTP-forward paths (Envoy :authority / r.Host header) and only safe-by- construction on the CONNECT-tunnel path. Operators should not list a host they would want IBAC / token-exchange to deny on. Hard-guard reminder (authlib/listener/forwardproxy/transparent.go): - HandleTransparentConn (proxy-sidecar enforce-redirect mode) intentionally does NOT consult SkipHosts. Add a paragraph to its godoc stating the omission is deliberate so a future maintainer does not "fix the inconsistency" by adding a skip keyed on the agent-controlled SNI/Host bytes and open a real bypass of the hard egress guard. Tests: - New TestForwardProxy_SkipHosts_MatchesAgainstHostHeaderNotURL locks the trust-model contract for the HTTP forward path: the skip keys on the agent-supplied Host header, not on r.URL.Host. - New TestForwardProxy_SkipHosts_CONNECT_BypassesPipeline exercises the previously-uncovered CONNECT skip path end-to-end. - New TestNew_RejectsMatchAllStar / RejectsMatchAllDoubleStar / RejectsWhitespaceOnly / RejectsPortInPattern lock the construction- time guards, plus TestNew_AcceptsLeadingStar guards against a future rewrite that over-rejects and breaks operator-typical FQDN patterns. CLAUDE.md gotcha #11: - Narrow the LastIntent() wording: the pin protects against FIFO eviction only; expired or deleted sessions can still return nil. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
1 parent 4773e4d commit e8e632d

8 files changed

Lines changed: 348 additions & 11 deletions

File tree

authbridge/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h
590590
```
591591
Any change to `listener.skip_hosts` requires a pod restart (same rule as other `listener.*` fields). Do NOT add hosts here that need IBAC / token-exchange policy applied — bypass means bypass.
592592

593-
- **Backstop — intent pin in the eviction policy**: even with `skip_hosts` empty, the session store now pins the most-recent inbound A2A request event against FIFO eviction. If the buffer overflows, every other event evicts in normal chronological order; the protected intent stays at its original timestamp, leaving a visible time gap in the timeline. Older intents from earlier turns are NOT pinned — only the latest one — so a multi-turn conversation with huge fan-out can't pile up stale intents and starve the buffer. This means IBAC's `LastIntent()` will return non-nil as long as ANY inbound A2A request landed in the bucket since session start, regardless of how chatty the outbound traffic is. The pin is defense-in-depth; reach for `skip_hosts` first when the offending traffic is identifiable infrastructure.
593+
- **Backstop — intent pin in the eviction policy**: even with `skip_hosts` empty, the session store now pins the most-recent inbound A2A request event against FIFO eviction. If the buffer overflows, every other event evicts in normal chronological order; the protected intent stays at its original timestamp, leaving a visible time gap in the timeline. Older intents from earlier turns are NOT pinned — only the latest one — so a multi-turn conversation with huge fan-out can't pile up stale intents and starve the buffer. The pin protects against FIFO eviction only: IBAC's `LastIntent()` survives buffer overflow as long as the session is still alive and an inbound A2A request has landed in it, but can still return nil after session expiry, explicit deletion, or before the first inbound request arrives. The pin is defense-in-depth; reach for `skip_hosts` first when the offending traffic is identifiable infrastructure.
594594

595595
## DCO Sign-Off (Mandatory)
596596

authbridge/authlib/config/config.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,42 @@ type ListenerConfig struct {
358358
//
359359
// Empty list (default) preserves current behavior: every outbound
360360
// host runs the pipeline and is eligible for session recording.
361+
//
362+
// Trust model — the value matched against SkipHosts is the
363+
// destination Host as observed at the listener boundary, which is
364+
// agent-influenceable in two of the three deployment shapes:
365+
//
366+
// - ext_proc / envoy-sidecar: matches Envoy's `:authority`
367+
// (fallback `host` header). The agent sets these; Envoy may
368+
// rewrite them per its config, but ultimately the value is
369+
// "what the agent told Envoy it wanted to talk to."
370+
// - HTTP forward-proxy / proxy-sidecar: matches `r.Host` from
371+
// the HTTP request. The request is then dialed against
372+
// `r.URL`, so a forged Host that diverges from the real URL
373+
// host would skip-match yet send to the actual upstream.
374+
// - CONNECT-tunnel / proxy-sidecar: safer-by-construction —
375+
// `r.Host` IS the dial target. A forged Host cannot
376+
// skip-match while dialing elsewhere.
377+
//
378+
// Implication: do NOT list a destination here that you'd want
379+
// IBAC / token-exchange to deny on. Skip means "the operator
380+
// trusts every flow to this host enough to bypass the entire
381+
// outbound enforcement pipeline." Limit entries to infrastructure
382+
// destinations the agent should not be making policy decisions
383+
// against in the first place (collector sidecars, log shippers).
384+
//
385+
// Each skip is logged at INFO with the matched host and pattern
386+
// so an operator reviewing logs can see when a pattern fired and
387+
// catch unexpected matches early. The `transparentproxy` listener
388+
// (proxy-sidecar enforce-redirect mode) intentionally does NOT
389+
// consult SkipHosts — that is the hard egress guard and must not
390+
// be self-exemptable via the agent's outbound destination.
391+
//
392+
// Match-all patterns ("*", "**", whitespace-only) and patterns
393+
// containing ":port" are rejected at startup so a single
394+
// misconfigured entry can't silently disable all outbound
395+
// enforcement. Mirrors the bypass-pattern guard added to ibac
396+
// in #496.
361397
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`
362398
}
363399

authbridge/authlib/listener/extproc/server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,9 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe
531531
// when the headers were already passed through — without checking
532532
// here, a skip-listed host whose request carries a body would still
533533
// run the pipeline on the body phase.
534-
if s.SkipHosts.Match(pctx.Host) {
534+
if pat, matched := s.SkipHosts.MatchPattern(pctx.Host); matched {
535+
slog.Info("ext_proc: skip_hosts match (body phase) — bypassing pipeline + session recording",
536+
"host", pctx.Host, "pattern", pat, "path", pctx.Path)
535537
return allowBodyResponse(), nil
536538
}
537539

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,19 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
210210
// response-phase work. RunFinish is also skipped (no defer
211211
// registered) because the pipeline never ran and has nothing to
212212
// finalize. See ListenerConfig.SkipHosts for motivation.
213-
skipped := s.SkipHosts.Match(pctx.Host)
213+
//
214+
// Audit log: Match keys on r.Host (the agent-supplied Host header
215+
// at the listener boundary), and the request is then dialed against
216+
// r.URL via s.Client.Do(r). A forged Host that diverges from the
217+
// dial target would skip-match yet send to a different upstream —
218+
// the same trust shape as ext_proc's :authority. Logging the host
219+
// + matched pattern at INFO leaves a per-skip audit trail so
220+
// successful self-exemption isn't invisible.
221+
pat, skipped := s.SkipHosts.MatchPattern(pctx.Host)
222+
if skipped {
223+
slog.Info("forward-proxy: skip_hosts match — bypassing pipeline + session recording",
224+
"host", pctx.Host, "pattern", pat, "method", r.Method, "path", r.URL.Path)
225+
}
214226

215227
// Finisher dispatch runs after every exit path. RunFinish is a
216228
// no-op when pctx.dispatched is empty (pre-pipeline rejects), so
@@ -707,7 +719,17 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
707719
// destination you'd want IBAC or token-exchange to deny on, that
708720
// denial does not happen — the SkipHosts list is a "trusted
709721
// infrastructure" surface, not a generic per-route policy knob.
710-
skipped := s.SkipHosts.Match(pctx.Host)
722+
//
723+
// CONNECT is safer-by-construction than the HTTP path: r.Host on
724+
// CONNECT is the dial target, so a forged Host header cannot
725+
// skip-match while dialing elsewhere — the proxy dials the same
726+
// "host:port" it matched. We still emit an audit log so a
727+
// successful skip leaves a trace.
728+
pat, skipped := s.SkipHosts.MatchPattern(pctx.Host)
729+
if skipped {
730+
slog.Info("forward-proxy: skip_hosts match (CONNECT) — opening tunnel without pipeline + recording",
731+
"host", pctx.Host, "pattern", pat)
732+
}
711733

712734
if !skipped {
713735
defer func() {

authbridge/authlib/listener/forwardproxy/skiphost_test.go

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package forwardproxy
22

33
import (
4+
"bufio"
45
"context"
56
"io"
7+
"net"
68
"net/http"
79
"net/http/httptest"
810
"strings"
@@ -196,5 +198,169 @@ func TestForwardProxy_SkipHosts_NilMatcherPreservesBehavior(t *testing.T) {
196198
}
197199
}
198200

199-
// silence unused-import warning if mustParseURL ends up only used here
200-
var _ = strings.TrimSpace
201+
// TestForwardProxy_SkipHosts_MatchesAgainstHostHeaderNotURL locks the
202+
// trust-model contract for the HTTP forward path: the skip match keys
203+
// on the request `Host` header (`r.Host`), not on the dial target
204+
// derived from `r.URL`. This is what huang195's review called out as
205+
// the agent-influenceable input — the test exists to make the behavior
206+
// observable so future maintainers see it when reading the test, and
207+
// so a refactor that flips this boundary (e.g. switching to r.URL.Host)
208+
// breaks loudly with a named test.
209+
//
210+
// Operators reading this: do NOT add a destination to skip_hosts that
211+
// you'd want IBAC / token-exchange to deny on. The skip is keyed on
212+
// agent-influenceable headers; the audit log emitted on each skip is
213+
// the only after-the-fact signal.
214+
//
215+
// The test scopes itself to the trust contract — "skip fires when
216+
// r.Host matches" — and does NOT assert what happens to the forwarded
217+
// request afterward. Go's http.Transport behavior with a proxy and a
218+
// forged Host header is implementation-detail that the trust model
219+
// shouldn't depend on; what matters is that the skip decision was
220+
// made on the agent-supplied value.
221+
func TestForwardProxy_SkipHosts_MatchesAgainstHostHeaderNotURL(t *testing.T) {
222+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
223+
w.WriteHeader(http.StatusOK)
224+
}))
225+
defer backend.Close()
226+
227+
store := session.New(5*time.Minute, 100, 0)
228+
defer store.Close()
229+
230+
// Skip pattern matches a hostname that is NOT the dial target.
231+
// The dial target is the backend (127.0.0.1:N from backend.URL);
232+
// the pattern is a fictitious hostname.
233+
skip, err := skiphost.New([]string{"infrastructure-only.example"})
234+
if err != nil {
235+
t.Fatalf("skiphost.New: %v", err)
236+
}
237+
238+
proxy, mp := newMarkerServer(t, store, skip)
239+
defer proxy.Close()
240+
241+
// Send a request whose Host header is the skip-listed pattern
242+
// while r.URL points at the real backend. A normal Go http.Client
243+
// would set Host from URL; we override it explicitly to model the
244+
// adversarial case.
245+
proxyClient := &http.Client{
246+
Transport: &http.Transport{
247+
Proxy: http.ProxyURL(mustParseURL(proxy.URL)),
248+
},
249+
}
250+
req, err := http.NewRequest("GET", backend.URL+"/test", nil)
251+
if err != nil {
252+
t.Fatalf("NewRequest: %v", err)
253+
}
254+
req.Host = "infrastructure-only.example" // forged Host header
255+
256+
// Don't assert on resp/err — the trust-model claim is solely
257+
// about the skip decision. Whatever Go's transport ends up doing
258+
// with the divergent Host vs URL is incidental.
259+
if resp, err := proxyClient.Do(req); err == nil && resp != nil {
260+
resp.Body.Close()
261+
}
262+
263+
// The skip matched against the forged Host, so the pipeline
264+
// did NOT run. That's the trust-contract assertion: the agent
265+
// chose the skip-match value via the Host header, not via the
266+
// real dial target.
267+
if mp.calls.Load() != 0 {
268+
t.Errorf("forged Host: pipeline ran %d times, want 0 — skip matched on r.Host (the agent-supplied Host header), not r.URL.Host", mp.calls.Load())
269+
}
270+
if sessions := store.ListSessions(); len(sessions) != 0 {
271+
t.Errorf("forged Host: %d session(s) recorded, want 0 — skip path bypasses recording", len(sessions))
272+
}
273+
}
274+
275+
// TestForwardProxy_SkipHosts_CONNECT_BypassesPipeline asserts that the
276+
// CONNECT-tunnel path honors SkipHosts. CONNECT is safer-by-construction
277+
// than the HTTP path because r.Host IS the dial target, but skip
278+
// behavior must still be exercised so a regression that disables the
279+
// CONNECT skip path is caught.
280+
func TestForwardProxy_SkipHosts_CONNECT_BypassesPipeline(t *testing.T) {
281+
// Minimal echo server — stand-in for an HTTPS upstream we don't
282+
// want to do TLS to in tests. The skip path opens a tunnel and
283+
// shuttles bytes; we just need to prove bytes flow.
284+
upstream, err := net.Listen("tcp", "127.0.0.1:0")
285+
if err != nil {
286+
t.Fatalf("listen upstream: %v", err)
287+
}
288+
defer upstream.Close()
289+
go func() {
290+
conn, err := upstream.Accept()
291+
if err != nil {
292+
return
293+
}
294+
defer conn.Close()
295+
buf := make([]byte, 64)
296+
n, _ := conn.Read(buf)
297+
_, _ = conn.Write(append([]byte("echo:"), buf[:n]...))
298+
}()
299+
300+
upstreamAddr := upstream.Addr().String()
301+
upstreamHost, _, _ := net.SplitHostPort(upstreamAddr)
302+
store := session.New(5*time.Minute, 100, 0)
303+
defer store.Close()
304+
305+
skip, err := skiphost.New([]string{upstreamHost})
306+
if err != nil {
307+
t.Fatalf("skiphost.New: %v", err)
308+
}
309+
310+
proxy, mp := newMarkerServer(t, store, skip)
311+
defer proxy.Close()
312+
313+
// Drive CONNECT manually so we can observe the 200 + tunnel.
314+
proxyAddr := strings.TrimPrefix(proxy.URL, "http://")
315+
tunnel, err := net.DialTimeout("tcp", proxyAddr, 5*time.Second)
316+
if err != nil {
317+
t.Fatalf("dial proxy: %v", err)
318+
}
319+
defer tunnel.Close()
320+
if _, err := tunnel.Write([]byte("CONNECT " + upstreamAddr + " HTTP/1.1\r\nHost: " + upstreamAddr + "\r\n\r\n")); err != nil {
321+
t.Fatalf("write CONNECT: %v", err)
322+
}
323+
324+
br := bufio.NewReader(tunnel)
325+
line, err := br.ReadString('\n')
326+
if err != nil {
327+
t.Fatalf("read CONNECT response: %v", err)
328+
}
329+
if !strings.Contains(line, "200") {
330+
t.Fatalf("CONNECT response = %q, want 200 (skip path must still establish the tunnel)", line)
331+
}
332+
// Drain headers.
333+
for {
334+
hdr, err := br.ReadString('\n')
335+
if err != nil {
336+
t.Fatalf("read headers: %v", err)
337+
}
338+
if hdr == "\r\n" || hdr == "\n" {
339+
break
340+
}
341+
}
342+
343+
// Tunnel up — write some bytes and confirm the echo round-trips.
344+
if _, err := tunnel.Write([]byte("hello")); err != nil {
345+
t.Fatalf("write through tunnel: %v", err)
346+
}
347+
got := make([]byte, 32)
348+
_ = tunnel.SetReadDeadline(time.Now().Add(2 * time.Second))
349+
n, err := br.Read(got)
350+
if err != nil && err != io.EOF {
351+
t.Fatalf("read tunnel: %v", err)
352+
}
353+
if string(got[:n]) != "echo:hello" {
354+
t.Errorf("tunnel echo = %q, want echo:hello", got[:n])
355+
}
356+
357+
// The skipped CONNECT must NOT have run the outbound pipeline
358+
// and must NOT have appended a session event.
359+
if mp.calls.Load() != 0 {
360+
t.Errorf("skipped CONNECT: pipeline ran %d times, want 0", mp.calls.Load())
361+
}
362+
if sessions := store.ListSessions(); len(sessions) != 0 {
363+
t.Errorf("skipped CONNECT: %d session(s) recorded, want 0", len(sessions))
364+
}
365+
}
366+

authbridge/authlib/listener/forwardproxy/transparent.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ import (
4242
// hard control against a hostile one — only the IP is ground truth. Hard
4343
// enforcement would need IP-set allowlists or SNI/cert cross-checks.
4444
//
45+
// SkipHosts is intentionally NOT consulted here. listener.skip_hosts is an
46+
// ops convenience for the cooperative-egress paths (forward proxy + ext_proc),
47+
// where bypassing the pipeline on infrastructure traffic is fine because the
48+
// agent is trusted to honor HTTP_PROXY anyway. The transparent path exists
49+
// precisely as the hard egress guard against agents that route around the
50+
// cooperative paths, and pctx.Host here is recovered from agent-controlled
51+
// SNI/Host bytes on the wire — making it self-exemptable would defeat the
52+
// reason this listener exists. If you find yourself wanting to add a
53+
// SkipHosts check here to "match the other listeners," don't — that's the
54+
// failure mode this comment is explicitly trying to prevent.
55+
//
4556
// HandleTransparentConn owns clientConn's lifecycle and always closes it.
4657
func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) {
4758
defer func() { _ = clientConn.Close() }()

0 commit comments

Comments
 (0)