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
31 changes: 31 additions & 0 deletions authbridge/authlib/plugins/a2aparser/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"log/slog"
"strings"

"github.com/rossoctl/cortex/authbridge/authlib/pipeline"
"github.com/rossoctl/cortex/authbridge/authlib/plugins"
Expand Down Expand Up @@ -48,6 +49,20 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
return pipeline.Action{Type: pipeline.Continue}
}

// Claim only A2A-namespace methods. Both A2A and MCP ride JSON-RPC 2.0
// over HTTP, so "the method is non-empty" cannot tell them apart — the
// method namespace is the only in-body signal that does. Gating here
// keeps this parser from claiming traffic that is JSON-RPC but belongs
// to another protocol — MCP (initialize, tools/list, notifications/*,
// ...) — or a non-JSON-RPC body such as an inference /v1/messages call
// (which unmarshals into JSONRPCRequest with an empty Method). Without
// it, abctl showed a phantom a2a-parser match on both MCP and inference
// traffic. mcp-parser has the mirror guard for its own namespace.
if !isA2AMethod(rpc.Method) {
slog.Debug("a2a-parser: not an A2A-namespace method, skipping", "method", rpc.Method, "bodyLen", len(pctx.Body))
return pipeline.Action{Type: pipeline.Continue}
}
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect whether JSONRPCRequest validates the JSON-RPC version during unmarshal.
rg -n -A30 -B3 'type JSONRPCRequest struct|UnmarshalJSON' authbridge

# Locate existing JSON-RPC-version checks and parser tests.
rg -n -A6 -B4 '"jsonrpc"|JSONRPC' authbridge

Repository: rossoctl/cortex

Length of output: 50374


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "=== parsercommon.go ==="
cat -n authbridge/authlib/plugins/internal/parsercommon/parsercommon.go | sed -n '1,180p'

echo "=== a2aparser plugin around entrypoint ==="
cat -n authbridge/authlib/plugins/a2aparser/plugin.go | sed -n '1,140p'

echo "=== mcpparser plugin around entrypoint ==="
cat -n authbridge/authlib/plugins/mcpparser/plugin.go | sed -n '200,250p'

echo "=== focused Unmarshal JSONRPC references ==="
rg -n -C 5 'JSONRPCRequest|jsonrpc' authbridge/authlib/plugins/internal/parsercommon authbridge/authlib/plugins/a2aparser authbridge/authlib/plugins/mcpparser

Repository: rossoctl/cortex

Length of output: 50371


Validate jsonrpc: "2.0" before accepting namespace-matched methods.

JSONRPCRequest has no custom unmarshaler, so valid JSON like {"method":"message/send","id":1} or {"method":"tools/list","id":1} can currently skip the namespace gate before being accepted. Add a jsonrpc discriminator check in both parsers, and add regression cases for method-bearing non-JSON-RPC payloads to both test files.

📍 Affects 4 files
  • authbridge/authlib/plugins/a2aparser/plugin.go#L61-L64 (this comment)
  • authbridge/authlib/plugins/mcpparser/plugin.go#L226-L228
  • authbridge/authlib/plugins/a2aparser/plugin_test.go#L333-L368
  • authbridge/authlib/plugins/mcpparser/plugin_test.go#L189-L230
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/plugins/a2aparser/plugin.go` around lines 61 - 64, The A2A
and MCP parsers accept namespace-matched methods without validating the JSON-RPC
version. In authbridge/authlib/plugins/a2aparser/plugin.go lines 61-64 and
authbridge/authlib/plugins/mcpparser/plugin.go lines 226-228, require the parsed
jsonrpc discriminator to equal "2.0" before accepting the method; update
authbridge/authlib/plugins/a2aparser/plugin_test.go lines 333-368 and
authbridge/authlib/plugins/mcpparser/plugin_test.go lines 189-230 with
regression cases for method-bearing payloads missing or using an invalid jsonrpc
value.

Source: Coding guidelines


ext := &pipeline.A2AExtension{
Method: rpc.Method,
RPCID: rpc.ID,
Expand Down Expand Up @@ -432,6 +447,22 @@ func parseA2AParts(rawParts []any) []pipeline.A2APart {
return parts
}

// isA2AMethod reports whether a JSON-RPC method name belongs to the A2A
// protocol namespace. A2A methods are slash-namespaced under message/,
// tasks/, and agent/ (per the A2A spec: message/send, message/stream,
// tasks/get, tasks/list, tasks/cancel, tasks/resubscribe,
// tasks/pushNotificationConfig/*, agent/getAuthenticatedExtendedCard).
// This positively identifies A2A traffic — declining MCP JSON-RPC and
// non-JSON-RPC bodies (empty method) that also unmarshal into a
// JSONRPCRequest. It stays forward-compatible within A2A's own namespace
// design: future message/*, tasks/*, and agent/* methods match without a
// code change.
func isA2AMethod(method string) bool {
return strings.HasPrefix(method, "message/") ||
strings.HasPrefix(method, "tasks/") ||
strings.HasPrefix(method, "agent/")
}

// isA2AAction reports whether an A2A JSON-RPC method name names a
// user-meaningful agent-to-agent call that guardrails should judge.
// Only methods that carry a user message into the agent (or out to
Expand Down
84 changes: 84 additions & 0 deletions authbridge/authlib/plugins/a2aparser/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,90 @@ func TestA2AParser_InvalidJSON(t *testing.T) {
}
}

// A JSON body with no JSON-RPC "method" is not A2A traffic. Inference
// payloads (Anthropic /v1/messages, OpenAI /v1/chat/completions) parse
// cleanly into JSONRPCRequest with a zero-value Method, so the parser
// must NOT attach an A2AExtension or record a "matched_" observe for
// them — otherwise abctl shows a phantom a2a-parser match on every
// inference call. Regression for the empty-method guard.
func TestA2AParser_NonJSONRPCBody_NoMatch(t *testing.T) {
cases := []struct {
name string
body string
}{
{"anthropic messages", `{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}`},
{"openai chat completions", `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`},
{"empty object", `{}`},
{"explicit empty method", `{"jsonrpc":"2.0","id":1,"method":"","params":{}}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := NewA2AParser()
pctx := &pipeline.Context{Body: []byte(tc.body)}

action := p.OnRequest(context.Background(), pctx)
if action.Type != pipeline.Continue {
t.Fatalf("expected Continue, got %v", action.Type)
}
if pctx.Extensions.A2A != nil {
t.Errorf("Extensions.A2A should be nil for non-JSON-RPC body, got %+v", pctx.Extensions.A2A)
}
// No Invocation should be recorded — the parser didn't claim
// this request, so abctl shows no a2a-parser row for it.
if pctx.Extensions.Invocations != nil {
t.Errorf("expected no Invocation recorded, got %+v", pctx.Extensions.Invocations)
}
})
}
}

// A2A and MCP both ride JSON-RPC 2.0, so a2a-parser must NOT claim MCP
// methods just because they carry a non-empty JSON-RPC method. Gating on
// the A2A namespace (message/, tasks/, agent/) makes it decline MCP
// traffic (initialize, tools/list, notifications/*, ...) — which is why
// an MCP request should show no a2a-parser row in abctl. Mirror of
// mcp-parser's own-namespace guard.
func TestA2AParser_ForeignNamespaceMethods_Declined(t *testing.T) {
mcpMethods := []string{
"initialize",
"ping",
"tools/list",
"tools/call",
"resources/read",
"prompts/get",
"notifications/initialized",
}
for _, method := range mcpMethods {
t.Run(method, func(t *testing.T) {
p := NewA2AParser()
pctx := &pipeline.Context{
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"` + method + `","params":{}}`),
}

action := p.OnRequest(context.Background(), pctx)
if action.Type != pipeline.Continue {
t.Fatalf("expected Continue, got %v", action.Type)
}
if pctx.Extensions.A2A != nil {
t.Errorf("Extensions.A2A should be nil for non-A2A method %q, got %+v", method, pctx.Extensions.A2A)
}
if pctx.Extensions.Invocations != nil {
t.Errorf("expected no Invocation for non-A2A method %q, got %+v", method, pctx.Extensions.Invocations)
}
// Declining (no extension attached) means Classification() reports
// "unclassified" — (anyAction=false, anyBypass=false) — NOT a
// recorded bypass. IBAC then applies unclassified_policy (default
// passthrough). Locks the interaction raised in review: scoping the
// parser to its namespace moves out-of-namespace JSON-RPC from
// "bypass" to "unclassified".
if anyAction, anyBypass := pctx.Classification(); anyAction || anyBypass {
t.Errorf("Classification() = (action=%v, bypass=%v) for non-A2A method %q; want (false, false) unclassified",
anyAction, anyBypass, method)
}
})
}
}

func TestA2AParser_MissingMessage(t *testing.T) {
p := NewA2AParser()
pctx := &pipeline.Context{
Expand Down
45 changes: 38 additions & 7 deletions authbridge/authlib/plugins/mcpparser/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"

"github.com/rossoctl/cortex/authbridge/authlib/bypass"
"github.com/rossoctl/cortex/authbridge/authlib/pipeline"
Expand Down Expand Up @@ -136,6 +137,32 @@ func isMCPAction(method string) bool {
return false
}

// isMCPMethod reports whether a JSON-RPC method name belongs to the MCP
// protocol namespace (spec revision 2025-06-18, Streamable HTTP). MCP
// methods are either the bare handshake verbs initialize/ping or are
// slash-namespaced under notifications/, tools/, resources/, prompts/,
// completion/, logging/, sampling/, roots/, and elicitation/. An empty
// method (non-JSON-RPC body) matches nothing and is declined. Gating on
// the namespace is what distinguishes MCP from A2A traffic, which also
// rides JSON-RPC 2.0 but under message/, tasks/, and agent/. When a
// future MCP revision adds a top-level namespace, extend this set (the
// spec-revision string above is the audit anchor).
func isMCPMethod(method string) bool {
switch method {
case "initialize", "ping":
return true
}
return strings.HasPrefix(method, "notifications/") ||
strings.HasPrefix(method, "tools/") ||
strings.HasPrefix(method, "resources/") ||
strings.HasPrefix(method, "prompts/") ||
strings.HasPrefix(method, "completion/") ||
strings.HasPrefix(method, "logging/") ||
strings.HasPrefix(method, "sampling/") ||
strings.HasPrefix(method, "roots/") ||
strings.HasPrefix(method, "elicitation/")
}

func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
// Body-less transport-layer detection. Scoped to the configured
// MCP-endpoint paths because there's no protocol payload to
Expand Down Expand Up @@ -187,13 +214,17 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body))
return pipeline.Action{Type: pipeline.Continue}
}
// Empty method → body parses as JSON but isn't a JSON-RPC request
// (e.g. an OpenAI chat/completions body also unmarshals into
// JSONRPCRequest with zero-value fields). Don't attach a useless
// MCPExtension to non-MCP traffic — downstream consumers shouldn't
// see a phantom "mcp: {}" on every inference event.
if rpc.Method == "" {
slog.Debug("mcp-parser: body is JSON but not JSON-RPC, skipping", "bodyLen", len(pctx.Body))
// Claim only MCP-namespace methods. Both MCP and A2A ride JSON-RPC 2.0
// over HTTP, so "the method is non-empty" cannot tell them apart — the
// method namespace is the only in-body signal that does. Gating here
// keeps mcp-parser from attaching an MCPExtension to A2A traffic
// (message/send, tasks/get, agent/*, ...) or to a non-JSON-RPC body
// such as an inference /v1/messages call (which unmarshals into
// JSONRPCRequest with an empty Method) — which would otherwise show a
// phantom "mcp: {}" on every such event. a2a-parser has the mirror
// guard for its own namespace.
if !isMCPMethod(rpc.Method) {
slog.Debug("mcp-parser: not an MCP-namespace method, skipping", "method", rpc.Method, "bodyLen", len(pctx.Body))
return pipeline.Action{Type: pipeline.Continue}
}

Expand Down
43 changes: 43 additions & 0 deletions authbridge/authlib/plugins/mcpparser/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,49 @@ func TestMCPParser_SkipsJSONThatIsNotJSONRPC(t *testing.T) {
}
}

// MCP and A2A both ride JSON-RPC 2.0, so mcp-parser must NOT claim A2A
// methods just because they carry a non-empty JSON-RPC method. Gating on
// the MCP namespace makes it decline A2A traffic (message/*, tasks/*,
// agent/*) — so an A2A request shows no mcp-parser row in abctl. Mirror
// of a2a-parser's own-namespace guard.
func TestMCPParser_ForeignNamespaceMethods_Declined(t *testing.T) {
a2aMethods := []string{
"message/send",
"message/stream",
"tasks/get",
"tasks/cancel",
"agent/getAuthenticatedExtendedCard",
}
for _, method := range a2aMethods {
t.Run(method, func(t *testing.T) {
p := NewMCPParser()
pctx := &pipeline.Context{
Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"` + method + `","params":{}}`),
}
action := p.OnRequest(context.Background(), pctx)
if action.Type != pipeline.Continue {
t.Fatalf("expected Continue, got %v", action.Type)
}
if pctx.Extensions.MCP != nil {
t.Errorf("Extensions.MCP should be nil for non-MCP method %q, got %+v", method, pctx.Extensions.MCP)
}
if pctx.Extensions.Invocations != nil {
t.Errorf("expected no Invocation for non-MCP method %q, got %+v", method, pctx.Extensions.Invocations)
}
// Declining (no extension attached) means Classification() reports
// "unclassified" — (anyAction=false, anyBypass=false) — NOT a
// recorded bypass. IBAC then applies unclassified_policy (default
// passthrough). Locks the interaction raised in review: scoping the
// parser to its namespace moves out-of-namespace JSON-RPC from
// "bypass" to "unclassified".
if anyAction, anyBypass := pctx.Classification(); anyAction || anyBypass {
t.Errorf("Classification() = (action=%v, bypass=%v) for non-MCP method %q; want (false, false) unclassified",
anyAction, anyBypass, method)
}
})
}
}

func TestMCPParser_InvalidJSON(t *testing.T) {
p := NewMCPParser()
pctx := &pipeline.Context{Body: []byte("not json")}
Expand Down
Loading