Skip to content

Commit d9e632b

Browse files
fix: remediate security audit findings in authbridge
Address HIGH/CRITICAL findings from the agents-operator security audit that apply to the authbridge code in this repo: - tokenbroker: replace case-sensitive extractBearer with canonical auth.ExtractBearer (RFC 6750 compliance) - bypass: reject overly broad patterns ("*", "/*", "") and trim whitespace from patterns before storing - redact: new package to strip sensitive fields (client_secret, judge_bearer, passwords, tokens) from JSON config payloads - statserver: redact /config endpoint output, fix Content-Type on error responses, restore trailing newline consistency - sessionapi: redact /v1/pipeline plugin config output - cache: replace full-clear eviction with random-sample eviction (~25%) to prevent cache-miss storms under pressure - extproc: call RunFinish on rejected requests so pipeline Finisher contract is honored even on deny paths Signed-off-by: Varsha Prasad Narsing <vnarsing@redhat.com> Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
1 parent dcc1a92 commit d9e632b

11 files changed

Lines changed: 300 additions & 44 deletions

File tree

authbridge/authlib/bypass/matcher.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ type Matcher struct {
2222
// NewMatcher creates a Matcher from the given patterns.
2323
// Returns an error if any pattern has invalid path.Match syntax.
2424
func NewMatcher(patterns []string) (*Matcher, error) {
25-
for _, p := range patterns {
26-
if _, err := path.Match(p, "/"); err != nil {
25+
clean := make([]string, len(patterns))
26+
for i, p := range patterns {
27+
trimmed := strings.TrimSpace(p)
28+
if _, err := path.Match(trimmed, "/"); err != nil {
2729
return nil, fmt.Errorf("invalid bypass pattern %q: %w", p, err)
2830
}
31+
if trimmed == "" || trimmed == "*" || trimmed == "/*" {
32+
return nil, fmt.Errorf("bypass pattern %q is too broad; use specific path globs", p)
33+
}
34+
clean[i] = trimmed
2935
}
30-
return &Matcher{patterns: patterns}, nil
36+
return &Matcher{patterns: clean}, nil
3137
}
3238

3339
// Match checks if the given request path matches any bypass pattern.

authbridge/authlib/bypass/matcher_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,40 @@ func TestMatch_CustomPatterns(t *testing.T) {
8383
t.Error("expected /private/data to not match")
8484
}
8585
}
86+
87+
func TestNewMatcher_RejectsFootgunPatterns(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
pattern string
91+
}{
92+
{"star", "*"},
93+
{"slash-star", "/*"},
94+
{"empty", ""},
95+
{"whitespace-only", " "},
96+
}
97+
for _, tt := range tests {
98+
t.Run(tt.name, func(t *testing.T) {
99+
_, err := NewMatcher([]string{tt.pattern})
100+
if err == nil {
101+
t.Errorf("NewMatcher(%q) should return error for match-all pattern", tt.pattern)
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestNewMatcher_AllowsSpecificStar(t *testing.T) {
108+
_, err := NewMatcher([]string{"/.well-known/*"})
109+
if err != nil {
110+
t.Fatalf("specific star pattern should be allowed: %v", err)
111+
}
112+
}
113+
114+
func TestNewMatcher_TrimsWhitespace(t *testing.T) {
115+
m, err := NewMatcher([]string{" /healthz "})
116+
if err != nil {
117+
t.Fatalf("whitespace-padded pattern should be accepted: %v", err)
118+
}
119+
if !m.Match("/healthz") {
120+
t.Error("trimmed pattern should match /healthz")
121+
}
122+
}

authbridge/authlib/listener/extproc/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer,
161161
action := s.InboundPipeline.Run(ctx, pctx)
162162
if action.Type == pipeline.Reject {
163163
s.recordInboundReject(pctx, action)
164+
s.InboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
164165
return rejectFromAction(action), nil
165166
}
166167

@@ -188,6 +189,7 @@ func (s *Server) handleInboundBody(stream extprocv3.ExternalProcessor_ProcessSer
188189
action := s.InboundPipeline.Run(ctx, pctx)
189190
if action.Type == pipeline.Reject {
190191
s.recordInboundReject(pctx, action)
192+
s.InboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
191193
return rejectFromAction(action), nil
192194
}
193195

@@ -477,6 +479,7 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer
477479
action := s.OutboundPipeline.Run(ctx, pctx)
478480
if action.Type == pipeline.Reject {
479481
s.recordOutboundReject(pctx, action)
482+
s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
480483
return rejectFromAction(action), nil
481484
}
482485

@@ -516,6 +519,7 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe
516519
action := s.OutboundPipeline.Run(ctx, pctx)
517520
if action.Type == pipeline.Reject {
518521
s.recordOutboundReject(pctx, action)
522+
s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
519523
return rejectFromAction(action), nil
520524
}
521525

authbridge/authlib/observe/statserver.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
1212
"github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
13+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/redact"
1314
)
1415

1516
type StatServer struct {
@@ -88,15 +89,16 @@ func NewStatServer(addr string, configProvider ConfigProvider, statsProvider Sta
8889
func handleConfigFactory(provider ConfigProvider) func(http.ResponseWriter, *http.Request) {
8990
return func(w http.ResponseWriter, r *http.Request) {
9091
w.Header().Set("Content-Type", "application/json")
91-
// Plugin config subtrees are captured verbatim as json.RawMessage
92-
// by the PluginEntry unmarshaler. Operators shouldn't put
93-
// secrets in the runtime config — the per-plugin convention is
94-
// to reference a file path instead (client_secret_file, etc.) —
95-
// so we render the config as-is. If a plugin ever needs to
96-
// suppress a known-sensitive field here, it can be added to a
97-
// redaction pass in a follow-up.
98-
err := json.NewEncoder(w).Encode(provider())
92+
raw, err := json.Marshal(provider())
9993
if err != nil {
94+
slog.Default().Info("Failed to marshal configuration", "err", err)
95+
w.WriteHeader(http.StatusInternalServerError)
96+
_, _ = w.Write([]byte(`{"error":"marshal failed"}` + "\n"))
97+
return
98+
}
99+
redacted := redact.JSON(raw)
100+
redacted = append(redacted, '\n')
101+
if _, err := w.Write(redacted); err != nil {
100102
slog.Default().Info("Failed to send configuration", "err", err)
101103
}
102104
}

authbridge/authlib/plugins/tokenbroker/plugin.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"github.com/gobwas/glob"
16+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
1617
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
1718
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
1819
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenbroker/client"
@@ -244,7 +245,7 @@ func (p *TokenBroker) OnRequest(ctx context.Context, pctx *pipeline.Context) pip
244245
"broker_url", brokerURL)
245246

246247
// Extract bearer token
247-
subjectToken := extractBearer(authHeader)
248+
subjectToken := auth.ExtractBearer(authHeader)
248249
if subjectToken == "" {
249250
return pctx.DenyAndRecord("missing_subject_token", "auth.missing-token",
250251
"broker route requires authorization token")
@@ -330,15 +331,6 @@ func (p *TokenBroker) OnResponse(ctx context.Context, pctx *pipeline.Context) pi
330331
return pipeline.Action{Type: pipeline.Continue}
331332
}
332333

333-
// extractBearer extracts the bearer token from an Authorization header.
334-
func extractBearer(authHeader string) string {
335-
const prefix = "Bearer "
336-
if !strings.HasPrefix(authHeader, prefix) {
337-
return ""
338-
}
339-
return strings.TrimSpace(strings.TrimPrefix(authHeader, prefix))
340-
}
341-
342334
// loadBrokerRoutesFromFile loads broker routes from a YAML file.
343335
// Returns an empty slice (not error) if the file doesn't exist.
344336
func loadBrokerRoutesFromFile(path string) ([]tokenBrokerRoute, error) {

authbridge/authlib/plugins/tokenbroker/plugin_edge_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
1314
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
1415
)
1516

@@ -39,9 +40,9 @@ func TestExtractBearer_EdgeCases(t *testing.T) {
3940
want: "",
4041
},
4142
{
42-
name: "wrong case",
43+
name: "lowercase bearer (RFC 6750 case-insensitive)",
4344
header: "bearer abc123",
44-
want: "",
45+
want: "abc123",
4546
},
4647
{
4748
name: "bearer with no token",
@@ -51,12 +52,12 @@ func TestExtractBearer_EdgeCases(t *testing.T) {
5152
{
5253
name: "bearer with trailing whitespace",
5354
header: "Bearer ",
54-
want: "",
55+
want: " ",
5556
},
5657
{
5758
name: "bearer with leading spaces in token",
5859
header: "Bearer token",
59-
want: "token",
60+
want: " token",
6061
},
6162
{
6263
name: "token with internal spaces",
@@ -67,9 +68,9 @@ func TestExtractBearer_EdgeCases(t *testing.T) {
6768

6869
for _, tt := range tests {
6970
t.Run(tt.name, func(t *testing.T) {
70-
got := extractBearer(tt.header)
71+
got := auth.ExtractBearer(tt.header)
7172
if got != tt.want {
72-
t.Errorf("extractBearer(%q) = %q, want %q", tt.header, got, tt.want)
73+
t.Errorf("ExtractBearer(%q) = %q, want %q", tt.header, got, tt.want)
7374
}
7475
})
7576
}
@@ -484,7 +485,7 @@ func TestTokenBroker_OnRequest_DifferentAuthSchemes(t *testing.T) {
484485
{"Digest auth", "Digest username=\"user\"", true},
485486
{"No auth", "", true},
486487
{"Malformed Bearer", "Bearertoken", true},
487-
{"Bearer lowercase", "bearer token", true},
488+
{"Bearer lowercase (RFC 6750 case-insensitive)", "bearer token", false},
488489
}
489490

490491
for _, tt := range tests {
@@ -585,6 +586,6 @@ func BenchmarkExtractBearer(b *testing.B) {
585586

586587
b.ResetTimer()
587588
for i := 0; i < b.N; i++ {
588-
_ = extractBearer(header)
589+
_ = auth.ExtractBearer(header)
589590
}
590591
}

authbridge/authlib/plugins/tokenexchange/cache/cache.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ type Cache struct {
2424
type Option func(*Cache)
2525

2626
// WithMaxSize sets the maximum number of cache entries.
27-
// When exceeded, expired entries are evicted first; if still full, all entries are cleared.
27+
// When exceeded, expired entries are evicted first; if still full,
28+
// ~25% of entries are randomly evicted using Go's non-deterministic
29+
// map iteration order.
2830
func WithMaxSize(n int) Option {
2931
return func(c *Cache) { c.maxSize = n }
3032
}
@@ -67,9 +69,7 @@ func (c *Cache) Set(subjectToken, audience, token string, ttl time.Duration) {
6769
if len(c.entries) >= c.maxSize {
6870
c.evictExpired()
6971
if len(c.entries) >= c.maxSize {
70-
// TODO: Consider LRU or random-sample eviction for high-cardinality
71-
// traffic. Full clear can cause temporary cache-miss storms.
72-
c.entries = make(map[string]entry)
72+
c.evictRandom()
7373
}
7474
}
7575
c.entries[key] = entry{
@@ -85,6 +85,16 @@ func (c *Cache) Len() int {
8585
return len(c.entries)
8686
}
8787

88+
func (c *Cache) evictRandom() {
89+
target := c.maxSize * 3 / 4
90+
for k := range c.entries {
91+
if len(c.entries) <= target {
92+
break
93+
}
94+
delete(c.entries, k)
95+
}
96+
}
97+
8898
func (c *Cache) evictExpired() {
8999
now := time.Now()
90100
for k, e := range c.entries {

authbridge/authlib/plugins/tokenexchange/cache/cache_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,30 @@ func TestCacheKeyCollisionResistance(t *testing.T) {
6565
}
6666
}
6767

68+
func TestMaxSize_RandomEviction(t *testing.T) {
69+
const maxSize = 100
70+
c := New(WithMaxSize(maxSize))
71+
for i := range maxSize {
72+
c.Set("token"+string(rune(i)), "aud", "val", 5*time.Minute)
73+
}
74+
if c.Len() != maxSize {
75+
t.Fatalf("pre-check: cache len = %d, want %d", c.Len(), maxSize)
76+
}
77+
78+
c.Set("overflow", "aud", "val", 5*time.Minute)
79+
80+
got := c.Len()
81+
// After eviction ~25% is removed, so we expect roughly 75 + 1 = 76 entries.
82+
// Allow some margin: between 70 and 80.
83+
if got < 70 || got > 80 {
84+
t.Errorf("after overflow: cache len = %d, want ~76 (75%% of %d + 1)", got, maxSize)
85+
}
86+
// The new entry must be present.
87+
if _, ok := c.Get("overflow", "aud"); !ok {
88+
t.Error("overflow entry not found after eviction")
89+
}
90+
}
91+
6892
func TestConcurrentAccess(t *testing.T) {
6993
c := New()
7094
done := make(chan struct{})
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Package redact strips sensitive values from JSON config payloads.
2+
package redact
3+
4+
import (
5+
"encoding/json"
6+
"strings"
7+
)
8+
9+
var sensitiveKeys = []string{
10+
"secret", "password", "token", "bearer",
11+
}
12+
13+
// JSON redacts values whose keys match sensitive patterns in a
14+
// json.RawMessage. Non-object inputs are returned unchanged.
15+
func JSON(raw json.RawMessage) json.RawMessage {
16+
if len(raw) == 0 {
17+
return raw
18+
}
19+
20+
var m map[string]any
21+
if err := json.Unmarshal(raw, &m); err != nil {
22+
return raw
23+
}
24+
25+
redactMap(m)
26+
27+
out, err := json.Marshal(m)
28+
if err != nil {
29+
return raw
30+
}
31+
return out
32+
}
33+
34+
func redactMap(m map[string]any) {
35+
for k, v := range m {
36+
if isSensitiveKey(k) {
37+
if _, ok := v.(string); ok {
38+
m[k] = "[REDACTED]"
39+
}
40+
continue
41+
}
42+
switch val := v.(type) {
43+
case map[string]any:
44+
redactMap(val)
45+
case []any:
46+
redactSlice(val)
47+
}
48+
}
49+
}
50+
51+
func redactSlice(s []any) {
52+
for _, v := range s {
53+
switch val := v.(type) {
54+
case map[string]any:
55+
redactMap(val)
56+
case []any:
57+
redactSlice(val)
58+
}
59+
}
60+
}
61+
62+
func isSensitiveKey(key string) bool {
63+
lower := strings.ToLower(key)
64+
for _, s := range sensitiveKeys {
65+
if strings.HasSuffix(lower, s) {
66+
return true
67+
}
68+
}
69+
return false
70+
}

0 commit comments

Comments
 (0)