diff --git a/internal/policy/engine.go b/internal/policy/engine.go index 9a85073..0563d78 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -193,11 +193,15 @@ func matchPath(pattern, path string) bool { // Supports * as a wildcard for a single path segment. // Supports ** or trailing * for matching multiple segments. func MatchGlob(pattern, path string) bool { - // Trailing wildcard: "/tasks*" or "/tasks/*" + // Trailing wildcard: "/tasks*" or "/tasks/*" matches multiple segments if strings.HasSuffix(pattern, "*") && !strings.HasSuffix(pattern, "**") { prefix := strings.TrimSuffix(pattern, "*") prefix = strings.TrimSuffix(prefix, "/") - return strings.HasPrefix(path, prefix) + if !strings.Contains(prefix, "*") { + return strings.HasPrefix(path, prefix) + } + // Mid-path wildcards present: promote trailing * to ** for segment matching + pattern = pattern[:len(pattern)-1] + "**" } patternParts := strings.Split(strings.Trim(pattern, "/"), "/") diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go index 6e59a1c..3cde300 100644 --- a/internal/policy/engine_test.go +++ b/internal/policy/engine_test.go @@ -191,6 +191,31 @@ func TestEngine_GlobPatternDoubleWildcard(t *testing.T) { } } +func TestEngine_GlobTrailingWildcardWithMidPathWildcard(t *testing.T) { + rules := []config.Rule{ + {Match: config.Match{Method: "POST", Path: "/client/v4/accounts/*/ai-search/*"}, Action: "allow"}, + {Match: config.Match{Method: "*"}, Action: "deny"}, + } + engine := New(rules) + + tests := []struct { + path string + expect Action + }{ + {"/client/v4/accounts/abc123/ai-search/query", Allow}, + {"/client/v4/accounts/abc123/ai-search/instances/kb/search", Allow}, + {"/client/v4/accounts/abc123/other/stuff", Deny}, + {"/client/v4/accounts/abc123/ai-search", Deny}, + } + + for _, tt := range tests { + decision := engine.Evaluate("POST", tt.path) + if decision.Action != tt.expect { + t.Errorf("POST %s: expected %v, got %v", tt.path, tt.expect, decision.Action) + } + } +} + func TestEngine_RateLimiting(t *testing.T) { rules := []config.Rule{ { diff --git a/internal/proxy/http.go b/internal/proxy/http.go index aacc8e8..e9c3b40 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -266,7 +266,6 @@ func (p *Proxy) resolveUpstream(r *http.Request) (*url.URL, error) { // processSealedHeaders decrypts X-Wardgate-Sealed-* headers and sets the real // headers on the outgoing request. Only headers in the allowed whitelist are processed. func (p *Proxy) processSealedHeaders(req *http.Request, incoming http.Header) { - // Remove agent's Wardgate auth header before setting decrypted values req.Header.Del("Authorization") for name, values := range incoming { @@ -279,19 +278,33 @@ func (p *Proxy) processSealedHeaders(req *http.Request, incoming http.Header) { req.Header.Del(name) continue } - req.Header.Del(realHeader) // clear before Add loop so only decrypted values remain + req.Header.Del(realHeader) for _, sealed := range values { - plaintext, err := p.sealer.Decrypt(sealed) + scheme, ciphertext := splitAuthScheme(sealed) + plaintext, err := p.sealer.Decrypt(ciphertext) if err != nil { log.Printf("failed to decrypt sealed header %q: %v", realHeader, err) continue } - req.Header.Add(realHeader, plaintext) + req.Header.Add(realHeader, scheme+plaintext) } req.Header.Del(name) } } +// splitAuthScheme strips a known HTTP auth scheme prefix (e.g. "Bearer ") from +// a sealed header value so the remaining base64 ciphertext can be decrypted. +// The prefix is returned separately so it can be re-added after decryption. +// Valid base64 never contains spaces, so this never false-matches a pure sealed blob. +func splitAuthScheme(value string) (scheme, ciphertext string) { + for _, prefix := range []string{"Bearer ", "Basic "} { + if strings.HasPrefix(value, prefix) { + return prefix, value[len(prefix):] + } + } + return "", value +} + // modifyResponse filters sensitive data from response bodies. func (p *Proxy) modifyResponse(resp *http.Response) error { // Skip if no filter configured or filter is disabled diff --git a/internal/proxy/http_test.go b/internal/proxy/http_test.go index 71f5aa1..f841d37 100644 --- a/internal/proxy/http_test.go +++ b/internal/proxy/http_test.go @@ -1056,6 +1056,66 @@ func TestProxy_SealedMultipleValuesForSameHeader(t *testing.T) { } } +func TestSplitAuthScheme(t *testing.T) { + tests := []struct { + input string + wantScheme string + wantRest string + }{ + {"Bearer abc123", "Bearer ", "abc123"}, + {"Basic dXNlcjpwYXNz", "Basic ", "dXNlcjpwYXNz"}, + {"abc123noprefix", "", "abc123noprefix"}, + {"", "", ""}, + {"bearer lowercase", "", "bearer lowercase"}, + } + for _, tt := range tests { + scheme, rest := splitAuthScheme(tt.input) + if scheme != tt.wantScheme || rest != tt.wantRest { + t.Errorf("splitAuthScheme(%q) = (%q, %q), want (%q, %q)", + tt.input, scheme, rest, tt.wantScheme, tt.wantRest) + } + } +} + +func TestProxy_SealedBearerPrefixStripped(t *testing.T) { + var receivedAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + sealer := newTestSealer(t) + // Seal just the API key (not "Bearer ") to match how the sandbox DO works + sealedKey, _ := sealer.Encrypt("sk-real-api-key-12345") + + vault := &mockVault{creds: map[string]string{}} + endpoint := config.Endpoint{ + Upstream: upstream.URL, + Auth: config.AuthConfig{Sealed: true}, + } + engine := policy.New([]config.Rule{{Match: config.Match{Method: "*"}, Action: "allow"}}) + + p := New(endpoint, vault, engine) + p.SetSealer(sealer) + p.SetAllowedSealHeaders(DefaultAllowedSealHeaders) + + req := httptest.NewRequest("POST", "/responses", nil) + req.Header.Set("Authorization", "Bearer agent-jwt") + // SDK adds "Bearer " prefix to the sealed key, proxy renames the full value + req.Header.Set("X-Wardgate-Sealed-Authorization", "Bearer "+sealedKey) + rec := httptest.NewRecorder() + + p.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if receivedAuth != "Bearer sk-real-api-key-12345" { + t.Errorf("expected decrypted auth with Bearer prefix, got %q", receivedAuth) + } +} + func TestProxy_DynamicUpstreamAllowed(t *testing.T) { var receivedHost string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {