Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/policy/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/"), "/")
Expand Down
25 changes: 25 additions & 0 deletions internal/policy/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
{
Expand Down
21 changes: 17 additions & 4 deletions internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions internal/proxy/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>") 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) {
Expand Down
Loading