Skip to content

Commit 07f2f4c

Browse files
committed
honor webhook http 422 as unconditional deny
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
1 parent c9a5402 commit 07f2f4c

6 files changed

Lines changed: 164 additions & 1 deletion

File tree

pkg/webhook/errors.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33

44
package webhook
55

6-
import "fmt"
6+
import (
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
)
711

812
// WebhookError is the base error type for all webhook-related errors.
913
//
@@ -92,3 +96,10 @@ func NewInvalidResponseError(webhookName string, err error, statusCode int) *Inv
9296
StatusCode: statusCode,
9397
}
9498
}
99+
100+
// IsAlwaysDenyError reports whether the webhook error should deny the request
101+
// regardless of the configured failure policy.
102+
func IsAlwaysDenyError(err error) bool {
103+
var invalidRespErr *InvalidResponseError
104+
return errors.As(err, &invalidRespErr) && invalidRespErr.StatusCode == http.StatusUnprocessableEntity
105+
}

pkg/webhook/errors_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,41 @@ func TestWebhookErrorBaseType(t *testing.T) {
8383
assert.Equal(t, `webhook "base-test": some error`, err.Error())
8484
assert.Equal(t, inner, err.Unwrap())
8585
}
86+
87+
func TestIsAlwaysDenyError(t *testing.T) {
88+
t.Parallel()
89+
90+
tests := []struct {
91+
name string
92+
err error
93+
want bool
94+
}{
95+
{
96+
name: "unprocessable entity invalid response",
97+
err: NewInvalidResponseError("test", fmt.Errorf("unprocessable"), 422),
98+
want: true,
99+
},
100+
{
101+
name: "other invalid response status",
102+
err: NewInvalidResponseError("test", fmt.Errorf("bad request"), 400),
103+
want: false,
104+
},
105+
{
106+
name: "invalid response without status",
107+
err: NewInvalidResponseError("test", fmt.Errorf("decode error"), 0),
108+
want: false,
109+
},
110+
{
111+
name: "non invalid response error",
112+
err: NewNetworkError("test", fmt.Errorf("network")),
113+
want: false,
114+
},
115+
}
116+
117+
for _, tt := range tests {
118+
t.Run(tt.name, func(t *testing.T) {
119+
t.Parallel()
120+
assert.Equal(t, tt.want, IsAlwaysDenyError(tt.err))
121+
})
122+
}
123+
}

pkg/webhook/mutating/middleware.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ func executeSingleMutation(
171171

172172
resp, err := exec.client.CallMutating(ctx, whReq)
173173
if err != nil {
174+
if webhook.IsAlwaysDenyError(err) {
175+
slog.Info("Mutating webhook denied request due to HTTP 422 response", "webhook", whName, "error", err)
176+
sendErrorResponse(w, http.StatusUnprocessableEntity, "Request denied by webhook policy", msgID)
177+
return nil, err
178+
}
179+
174180
if exec.config.FailurePolicy == webhook.FailurePolicyIgnore {
175181
slog.Warn("Mutating webhook error ignored due to fail-open policy", "webhook", whName, "error", err)
176182
return currentBody, nil

pkg/webhook/mutating/middleware_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,47 @@ func TestMutatingMiddleware_WebhookError_IgnorePolicy(t *testing.T) {
207207
assert.JSONEq(t, reqBody, string(capturedBody))
208208
}
209209

210+
//nolint:paralleltest // Uses httptest server.
211+
func TestMutatingMiddleware_HTTP422AlwaysDenies(t *testing.T) {
212+
tests := []struct {
213+
name string
214+
failurePolicy webhook.FailurePolicy
215+
}{
216+
{
217+
name: "fail policy",
218+
failurePolicy: webhook.FailurePolicyFail,
219+
},
220+
{
221+
name: "ignore policy",
222+
failurePolicy: webhook.FailurePolicyIgnore,
223+
},
224+
}
225+
226+
for _, tt := range tests {
227+
tt := tt
228+
t.Run(tt.name, func(t *testing.T) {
229+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
230+
w.WriteHeader(http.StatusUnprocessableEntity)
231+
_, _ = w.Write([]byte("unprocessable request"))
232+
}))
233+
defer server.Close()
234+
235+
cfg := makeConfig(server.URL, tt.failurePolicy)
236+
mw := createMutatingHandler(makeExecutors(t, []webhook.Config{cfg}), "srv", "stdio")
237+
238+
var nextCalled bool
239+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { nextCalled = true })
240+
241+
rr := httptest.NewRecorder()
242+
mw(nextHandler).ServeHTTP(rr, makeMCPRequest(t, []byte(`{"jsonrpc":"2.0","id":1}`)))
243+
244+
assert.False(t, nextCalled)
245+
assert.Equal(t, http.StatusUnprocessableEntity, rr.Code)
246+
assert.Contains(t, rr.Body.String(), "Request denied by webhook policy")
247+
})
248+
}
249+
}
250+
210251
func TestMutatingMiddleware_ScopeViolation_FailPolicy(t *testing.T) {
211252
t.Parallel()
212253
// Webhook tries to patch /principal/email — security violation.

pkg/webhook/validating/middleware.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ func createValidatingHandler(executors []clientExecutor, serverName, transport s
125125

126126
resp, err := exec.client.Call(r.Context(), whReq)
127127
if err != nil {
128+
if webhook.IsAlwaysDenyError(err) {
129+
slog.Info("Validating webhook denied request due to HTTP 422 response",
130+
"webhook", whName, "error", err)
131+
sendErrorResponse(w, http.StatusForbidden, "Request denied by policy", parsedMCP.ID)
132+
return
133+
}
134+
128135
// Handle error based on failure policy
129136
if exec.config.FailurePolicy == webhook.FailurePolicyIgnore {
130137
slog.Warn("Validating webhook error ignored due to fail-open policy",

pkg/webhook/validating/middleware_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,66 @@ func TestCreateMiddleware(t *testing.T) {
356356
require.NoError(t, mw.Close())
357357
}
358358

359+
//nolint:paralleltest // Uses httptest server.
360+
func TestValidatingMiddleware_HTTP422AlwaysDenies(t *testing.T) {
361+
tests := []struct {
362+
name string
363+
failurePolicy webhook.FailurePolicy
364+
}{
365+
{
366+
name: "fail policy",
367+
failurePolicy: webhook.FailurePolicyFail,
368+
},
369+
{
370+
name: "ignore policy",
371+
failurePolicy: webhook.FailurePolicyIgnore,
372+
},
373+
}
374+
375+
for _, tt := range tests {
376+
tt := tt
377+
t.Run(tt.name, func(t *testing.T) {
378+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
379+
w.WriteHeader(http.StatusUnprocessableEntity)
380+
_, _ = w.Write([]byte("unprocessable request"))
381+
}))
382+
defer server.Close()
383+
384+
cfg := webhook.Config{
385+
Name: "test-webhook",
386+
URL: server.URL,
387+
Timeout: webhook.DefaultTimeout,
388+
FailurePolicy: tt.failurePolicy,
389+
TLSConfig: &webhook.TLSConfig{
390+
InsecureSkipVerify: true,
391+
},
392+
}
393+
394+
client, err := webhook.NewClient(cfg, webhook.TypeValidating, nil)
395+
require.NoError(t, err)
396+
397+
mw := createValidatingHandler([]clientExecutor{{client: client, config: cfg}}, "test-server", "stdio")
398+
399+
reqBody := []byte(`{"jsonrpc":"2.0","method":"tools/call","id":1}`)
400+
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
401+
ctx := context.WithValue(req.Context(), mcp.MCPRequestContextKey, &mcp.ParsedMCPRequest{Method: "tools/call", ID: 1})
402+
req = req.WithContext(ctx)
403+
404+
var nextCalled bool
405+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
406+
nextCalled = true
407+
})
408+
409+
rr := httptest.NewRecorder()
410+
mw(nextHandler).ServeHTTP(rr, req)
411+
412+
assert.False(t, nextCalled)
413+
assert.Equal(t, http.StatusForbidden, rr.Code)
414+
assert.Contains(t, rr.Body.String(), "Request denied by policy")
415+
})
416+
}
417+
}
418+
359419
//nolint:paralleltest // Shares a mock HTTP server and lastRequest state
360420
func TestMultiWebhookChain(t *testing.T) {
361421
// Setup mock webhook servers

0 commit comments

Comments
 (0)