Skip to content

Commit 2fce417

Browse files
authored
Merge pull request #496 from varshaprasad96/fix/security-audit-remediations
Fix: remediate security audit findings in authbridge
2 parents 21663be + 8edd421 commit 2fce417

11 files changed

Lines changed: 334 additions & 45 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 & 11 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"
@@ -243,8 +244,7 @@ func (p *TokenBroker) OnRequest(ctx context.Context, pctx *pipeline.Context) pip
243244
"server_url", serverURL,
244245
"broker_url", brokerURL)
245246

246-
// Extract bearer token
247-
subjectToken := extractBearer(authHeader)
247+
subjectToken := strings.TrimSpace(auth.ExtractBearer(authHeader))
248248
if subjectToken == "" {
249249
return pctx.DenyAndRecord("missing_subject_token", "auth.missing-token",
250250
"broker route requires authorization token")
@@ -330,15 +330,6 @@ func (p *TokenBroker) OnResponse(ctx context.Context, pctx *pipeline.Context) pi
330330
return pipeline.Action{Type: pipeline.Continue}
331331
}
332332

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-
342333
// loadBrokerRoutesFromFile loads broker routes from a YAML file.
343334
// Returns an empty slice (not error) if the file doesn't exist.
344335
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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Package redact provides best-effort stripping of sensitive values from
2+
// JSON config payloads served by unauthenticated diagnostic endpoints.
3+
// The canonical defense is to keep inline secrets out of config entirely
4+
// (use *_file paths instead); this layer is defense-in-depth.
5+
package redact
6+
7+
import (
8+
"encoding/json"
9+
"strings"
10+
)
11+
12+
var sensitiveKeys = []string{
13+
"secret", "password", "token", "bearer", "key", "credential",
14+
}
15+
16+
// JSON redacts values whose keys match sensitive patterns in a
17+
// json.RawMessage. Non-object inputs are returned unchanged.
18+
func JSON(raw json.RawMessage) json.RawMessage {
19+
if len(raw) == 0 {
20+
return raw
21+
}
22+
23+
var m map[string]any
24+
if err := json.Unmarshal(raw, &m); err != nil {
25+
return raw
26+
}
27+
28+
redactMap(m)
29+
30+
out, err := json.Marshal(m)
31+
if err != nil {
32+
return raw
33+
}
34+
return out
35+
}
36+
37+
func redactMap(m map[string]any) {
38+
for k, v := range m {
39+
if isSensitiveKey(k) {
40+
if _, ok := v.(string); ok {
41+
m[k] = "[REDACTED]"
42+
continue
43+
}
44+
}
45+
switch val := v.(type) {
46+
case map[string]any:
47+
redactMap(val)
48+
case []any:
49+
redactSlice(val)
50+
}
51+
}
52+
}
53+
54+
func redactSlice(s []any) {
55+
for _, v := range s {
56+
switch val := v.(type) {
57+
case map[string]any:
58+
redactMap(val)
59+
case []any:
60+
redactSlice(val)
61+
}
62+
}
63+
}
64+
65+
func isSensitiveKey(key string) bool {
66+
lower := strings.ToLower(key)
67+
for _, s := range sensitiveKeys {
68+
if strings.HasSuffix(lower, s) {
69+
return true
70+
}
71+
}
72+
return false
73+
}

0 commit comments

Comments
 (0)