Skip to content
Merged
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
18 changes: 16 additions & 2 deletions pkg/audit/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,11 @@ func (*Auditor) isMCPStreamOpenRequest(r *http.Request) bool {

// ensureAuditContext injects the mutable carriers the auditor reads after the
// inner chain returns: BackendInfo (backend routing), an auth.IdentityHolder
// (identity attached by an auth middleware running INSIDE audit), and an
// (identity attached by an auth middleware running INSIDE audit), an
// mcp.ParsedRequestHolder (parsed MCP data from a parser running INSIDE
// audit). Each is only injected when absent so nested auditors share carriers.
// audit), and an mcp.AuthzDenialMarker (pre-parse refusals flagged by the
// authz middleware running INSIDE audit). Each is only injected when absent
// so nested auditors share carriers.
func ensureAuditContext(r *http.Request) *http.Request {
ctx := r.Context()
changed := false
Expand All @@ -194,6 +196,10 @@ func ensureAuditContext(r *http.Request) *http.Request {
ctx = mcp.WithParsedRequestHolder(ctx, &mcp.ParsedRequestHolder{})
changed = true
}
if _, ok := mcp.AuthzDenialMarkerFromContext(ctx); !ok {
ctx = mcp.WithAuthzDenialMarker(ctx, &mcp.AuthzDenialMarker{})
changed = true
}
if !changed {
return r
}
Expand Down Expand Up @@ -275,6 +281,14 @@ func (a *Auditor) logAuditEvent(r *http.Request, rw *responseWriter, requestData
// Determine outcome based on status code
outcome := a.determineOutcome(rw.statusCode)

// A refusal by the authz middleware before message-level authorization
// could run (e.g. a non-JSON POST carrying a smuggled JSON-RPC body)
// writes a 400, which determineOutcome maps to a generic failure. The
// marker reclassifies it as a denial so blocked sweeps are alertable.
if marker, ok := mcp.AuthzDenialMarkerFromContext(r.Context()); ok && marker.Denied {
outcome = OutcomeDenied
}

// When HTTP status indicates success, check for JSON-RPC errors
// hidden inside HTTP 200 responses.
var mcpResponse *mcp.ParsedMCPResponse
Expand Down
23 changes: 23 additions & 0 deletions pkg/audit/auditor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1441,6 +1441,29 @@ func TestMiddlewareAuditsInnerChainOutcomes(t *testing.T) {
"no identity exists when authentication fails")
})

t.Run("authz non-JSON refusal is audited as denied", func(t *testing.T) {
t.Parallel()
auditor, logBuf := newBufferAuditor(t)

// Mirror the authz middleware's refusal path: flag the injected
// marker and write the 400, as the explicit early return does.
refuse := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if marker, ok := mcp.AuthzDenialMarkerFromContext(r.Context()); ok {
marker.Denied = true
}
http.Error(w, "Invalid or malformed MCP request", http.StatusBadRequest)
})
req := httptest.NewRequest(http.MethodPost, "/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"target_tool","arguments":{}}}`))
req.Header.Set("Content-Type", "text/plain")
auditor.Middleware(refuse).ServeHTTP(httptest.NewRecorder(), req)

events := decodeAuditEvents(t, logBuf)
require.Len(t, events, 1)
assert.Equal(t, OutcomeDenied, events[0]["outcome"],
"an authz refusal before message-level authorization must audit as denied, not a generic 400 failure")
})

t.Run("event type comes from inner parser via holder", func(t *testing.T) {
t.Parallel()
auditor, logBuf := newBufferAuditor(t)
Expand Down
39 changes: 33 additions & 6 deletions pkg/authz/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,12 @@ var MCPMethodToFeatureOperation = map[string]featureOperation{
}

// shouldSkipInitialAuthorization checks if the request should skip authorization
// before reading the request body.
// before reading the request body. Content-Type is deliberately NOT consulted
// here: the middleware body refuses non-JSON POSTs with an explicit early
// return before this function is reached.
func shouldSkipInitialAuthorization(r *http.Request) bool {
// Skip authorization for non-POST requests and non-JSON content types
if r.Method != http.MethodPost || !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
// Skip authorization for non-POST requests
if r.Method != http.MethodPost {
return true
}

Expand Down Expand Up @@ -166,6 +168,13 @@ func handleUnauthorized(w http.ResponseWriter, msgID interface{}, err error) {
_ = mcp.WriteJSONRPCError(w, http.StatusForbidden, errorResponse)
}

// rejectInvalidMCPRequest writes the 400 response for requests that arrive
// without a parsed MCP message: non-JSON POSTs refused by the middleware
// (the load-bearing security refusal) and malformed JSON POSTs.
func rejectInvalidMCPRequest(w http.ResponseWriter) {
http.Error(w, "Invalid or malformed MCP request", http.StatusBadRequest)
}

// Middleware creates an HTTP middleware that authorizes MCP requests.
// This middleware extracts the MCP message from the request, determines the feature,
// operation, and resource ID, and authorizes the request using the configured authorizer.
Expand All @@ -188,6 +197,22 @@ func Middleware(a authorizers.Authorizer, next http.Handler, passThroughTools ma
annotationCache := NewAnnotationCache()

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Non-JSON POSTs are rejected deliberately and must never be passed
// through. Such a request is not parsed as MCP, so message-level
// authorization cannot run, but the proxy still forwards the body
// verbatim and MCP backends parse JSON-RPC without checking
// Content-Type. This early return is load-bearing for security: it is
// the only point that keeps a JSON-RPC body smuggled under text/plain
// from reaching the backend un-authorized. The marker lets the outer
// audit middleware record the refusal as a denial, not a 400 failure.
if r.Method == http.MethodPost && !mcp.RequestHasJSONContentType(r) {
if marker, ok := mcp.AuthzDenialMarkerFromContext(r.Context()); ok {
marker.Denied = true
}
rejectInvalidMCPRequest(w)
return
}

// Check if we should skip authorization before checking parsed data
if shouldSkipInitialAuthorization(r) {
next.ServeHTTP(w, r)
Expand All @@ -197,9 +222,11 @@ func Middleware(a authorizers.Authorizer, next http.Handler, passThroughTools ma
// Get parsed MCP request from context (set by parsing middleware)
parsedRequest := mcp.GetParsedMCPRequest(r.Context())
if parsedRequest == nil {
// No parsed MCP request available for a request that should have been parsed
// This indicates either a malformed request or missing parsing middleware
http.Error(w, "Invalid or malformed MCP request", http.StatusBadRequest)
// Non-JSON POSTs are already rejected by the early return above,
// so a nil parsed request here means a malformed JSON body or a
// missing parsing middleware. This branch is now only a
// belt-and-braces fallback behind the content-type refusal.
rejectInvalidMCPRequest(w)
return
}

Expand Down
98 changes: 98 additions & 0 deletions pkg/authz/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/golang-jwt/jwt/v5"
Expand Down Expand Up @@ -513,6 +514,103 @@ func TestMiddlewareWithGETRequest(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code, "Response status code should be OK")
}

func TestMiddlewareRejectsNonJSONPost(t *testing.T) {
Comment thread
jhrozek marked this conversation as resolved.
t.Parallel()
// Even a permissive policy must not see this request: a non-JSON POST is
// never parsed as MCP, so message-level authorization cannot run, while the
// proxy would still forward the body verbatim to a backend that parses
// JSON-RPC without checking Content-Type.
authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{
Policies: []string{
`permit(principal, action, resource);`,
},
EntitiesJSON: `[]`,
}, "")
require.NoError(t, err, "Failed to create Cedar authorizer")

var handlerCalled bool
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})

middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, handler, nil))

body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"weather","arguments":{}}}`
req, err := http.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
require.NoError(t, err, "Failed to create HTTP request")
req.Header.Set("Content-Type", "text/plain")

rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)

assert.False(t, handlerCalled, "handler must not be reached by a non-JSON POST carrying JSON-RPC")
assert.Equal(t, http.StatusBadRequest, rr.Code, "non-JSON POST should be rejected")
}

// TestMiddlewareNonJSONPostVariants pins the content-type handling around the
// explicit non-JSON refusal: which declarations still authorize and reach the
// handler, and which get the 400.
func TestMiddlewareNonJSONPostVariants(t *testing.T) {
t.Parallel()

body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"weather","arguments":{}}}`

cases := []struct {
name string
path string
contentType string // empty means the header is not set at all
wantStatus int
wantHandled bool
}{
{"charset variant still authorizes", "/mcp", "application/json; charset=utf-8", http.StatusOK, true},
{"uppercase media type still authorizes", "/mcp", "Application/JSON", http.StatusOK, true},
{"missing Content-Type is rejected", "/mcp", "", http.StatusBadRequest, false},
{"non-JSON POST to SSE path is rejected", "/sse", "text/plain", http.StatusBadRequest, false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{
Policies: []string{
`permit(principal, action, resource);`,
},
EntitiesJSON: `[]`,
}, "")
require.NoError(t, err, "Failed to create Cedar authorizer")

var handlerCalled bool
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, handler, nil))

req, err := http.NewRequest(http.MethodPost, tc.path, strings.NewReader(body))
require.NoError(t, err, "Failed to create HTTP request")
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}

// Attach an identity so the permissive policy has a principal to
// authorize, matching the other authorized-call tests.
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
Subject: "test-user",
Claims: jwt.MapClaims{"sub": "test-user"},
}}
req = req.WithContext(auth.WithIdentity(req.Context(), identity))

rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)

assert.Equal(t, tc.wantHandled, handlerCalled, "handler reached")
assert.Equal(t, tc.wantStatus, rr.Code, "response status")
})
}
}

func TestFactoryCreateMiddleware(t *testing.T) {
t.Parallel()

Expand Down
49 changes: 47 additions & 2 deletions pkg/mcp/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -212,6 +213,51 @@ func ParsedRequestHolderFromContext(ctx context.Context) (*ParsedRequestHolder,
return holder, ok && holder != nil
}

// authzDenialMarkerContextKey is the context key for AuthzDenialMarker.
type authzDenialMarkerContextKey struct{}

// AuthzDenialMarker is a mutable carrier that lets the authorization
// middleware (pkg/authz), which runs INSIDE the audit middleware, flag a
// request it refused before message-level authorization could run, such as a
// non-JSON POST carrying a smuggled JSON-RPC body. It follows the same
// propagation pattern as ParsedRequestHolder: the audit wrapper injects an
// empty marker via WithAuthzDenialMarker, the inner middleware fills it, and
// the wrapper reads it back after the inner chain returns so the refusal is
// audited as a denial rather than a generic 400 failure. The type lives in
// this package because pkg/authz already depends on pkg/audit transitively,
// so the carrier cannot live in either of those two packages.
//
// Like ParsedRequestHolder, the marker is written and read by the single
// request goroutine, so no synchronization is needed.
type AuthzDenialMarker struct {
Denied bool
}

// WithAuthzDenialMarker returns a new context carrying the given marker.
func WithAuthzDenialMarker(ctx context.Context, marker *AuthzDenialMarker) context.Context {
return context.WithValue(ctx, authzDenialMarkerContextKey{}, marker)
}

// AuthzDenialMarkerFromContext retrieves the AuthzDenialMarker from the
// context. Returns (nil, false) if no marker is present.
func AuthzDenialMarkerFromContext(ctx context.Context) (*AuthzDenialMarker, bool) {
marker, ok := ctx.Value(authzDenialMarkerContextKey{}).(*AuthzDenialMarker)
return marker, ok && marker != nil
}

// RequestHasJSONContentType reports whether r declares an application/json
// Content-Type. Media types are case-insensitive per RFC 9110 8.3.1, so the
// header is parsed with mime.ParseMediaType and compared with EqualFold; the
// case-sensitive prefix match this replaces also wrongly accepted longer
// types such as "application/jsonx".
func RequestHasJSONContentType(r *http.Request) bool {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return false
}
return strings.EqualFold(mediaType, "application/json")
}

// GetParsedMCPRequest retrieves the parsed MCP request from the request context.
// Returns nil if no parsed request is available.
func GetParsedMCPRequest(ctx context.Context) *ParsedMCPRequest {
Expand All @@ -228,8 +274,7 @@ func shouldParseMCPRequest(r *http.Request) bool {
return false
}

contentType := r.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
if !RequestHasJSONContentType(r) {
return false
}

Expand Down
Loading