From c43cc38956a720c8de366db3f94cbc7ebe38f747 Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Thu, 24 Sep 2026 11:06:53 -0400 Subject: [PATCH] Fix: Decline non-object JSON bodies as foreign traffic in the JSON-RPC parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a2a-parser and mcp-parser logged a top-level JSON array as "invalid JSON-RPC" with an unmarshal error, because json.Unmarshal into the JSONRPCRequest struct fails before the namespace guard that commit f3452845 added for the sibling case. An array body is not malformed traffic, though — it is simply a body that was never JSON-RPC, the same category as the inference bodies that guard already declines calmly. Peek the first non-whitespace byte before the unmarshal and route non-object bodies (array, string, number, null, whitespace-only) into the existing "not an X-namespace method, skipping" line. No new log call and no new message string; the "invalid" branch survives, now narrowed to bodies that start with '{' and still fail to decode, where the word is accurate. null already took the calm path, so it is observably unchanged. Both skip lines also gain host and path, which the report needed to identify the sender at all. pipeline.Context.Path is query-stripped by every listener, so no query handling is needed. Fixes #1084 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- .../authlib/plugins/a2aparser/plugin.go | 20 ++++++++++++++----- .../authlib/plugins/a2aparser/plugin_test.go | 6 ++++++ .../authlib/plugins/mcpparser/plugin.go | 20 ++++++++++++++----- .../authlib/plugins/mcpparser/plugin_test.go | 20 +++++++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/authbridge/authlib/plugins/a2aparser/plugin.go b/authbridge/authlib/plugins/a2aparser/plugin.go index d9fc8f852..079f855f3 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin.go +++ b/authbridge/authlib/plugins/a2aparser/plugin.go @@ -43,10 +43,19 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } + // A top-level non-object body (array, string, number, null) is not + // JSON-RPC; peek the first byte so it takes the calm skip path below + // rather than logging as malformed. + trimmed := bytes.TrimLeft(pctx.Body, " \t\r\n") + isObject := len(trimmed) > 0 && trimmed[0] == '{' + var rpc parsercommon.JSONRPCRequest - if err := json.Unmarshal(pctx.Body, &rpc); err != nil { - slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) - return pipeline.Action{Type: pipeline.Continue} + if isObject { + if err := json.Unmarshal(pctx.Body, &rpc); err != nil { + slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body), + "host", pctx.Host, "path", pctx.Path) + return pipeline.Action{Type: pipeline.Continue} + } } // Claim only A2A-namespace methods. Both A2A and MCP ride JSON-RPC 2.0 @@ -58,8 +67,9 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin // (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)) + if !isObject || !isA2AMethod(rpc.Method) { + slog.Debug("a2a-parser: not an A2A-namespace method, skipping", "method", rpc.Method, + "bodyLen", len(pctx.Body), "host", pctx.Host, "path", pctx.Path) return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/a2aparser/plugin_test.go b/authbridge/authlib/plugins/a2aparser/plugin_test.go index 3f750775e..55bb2787c 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin_test.go +++ b/authbridge/authlib/plugins/a2aparser/plugin_test.go @@ -345,6 +345,12 @@ func TestA2AParser_NonJSONRPCBody_NoMatch(t *testing.T) { {"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":{}}`}, + // Non-object JSON: declined on shape, before any method check. The + // array wraps a real A2A method on purpose — it would match + // isA2AMethod if it were an object. + {"top-level array", `[{"jsonrpc":"2.0","id":1,"method":"message/send"}]`}, + {"top-level string", `"hello"`}, + {"top-level number", `42`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/authbridge/authlib/plugins/mcpparser/plugin.go b/authbridge/authlib/plugins/mcpparser/plugin.go index bb409a98b..95a4cc842 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -209,10 +209,19 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } + // A top-level non-object body (array, string, number, null) is not + // JSON-RPC; peek the first byte so it takes the calm skip path below + // rather than logging as malformed. + trimmed := bytes.TrimLeft(pctx.Body, " \t\r\n") + isObject := len(trimmed) > 0 && trimmed[0] == '{' + var rpc parsercommon.JSONRPCRequest - if err := json.Unmarshal(pctx.Body, &rpc); err != nil { - slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) - return pipeline.Action{Type: pipeline.Continue} + if isObject { + if err := json.Unmarshal(pctx.Body, &rpc); err != nil { + slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body), + "host", pctx.Host, "path", pctx.Path) + return pipeline.Action{Type: pipeline.Continue} + } } // 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 @@ -223,8 +232,9 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin // 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)) + if !isObject || !isMCPMethod(rpc.Method) { + slog.Debug("mcp-parser: not an MCP-namespace method, skipping", "method", rpc.Method, + "bodyLen", len(pctx.Body), "host", pctx.Host, "path", pctx.Path) return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/mcpparser/plugin_test.go b/authbridge/authlib/plugins/mcpparser/plugin_test.go index 1b93972b3..30c808252 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin_test.go +++ b/authbridge/authlib/plugins/mcpparser/plugin_test.go @@ -186,6 +186,26 @@ func TestMCPParser_SkipsJSONThatIsNotJSONRPC(t *testing.T) { } } +// A top-level JSON array is not JSON-RPC, so it is declined on shape +// before any method check — the body here wraps a real MCP method that +// would match isMCPMethod if it were an object. +func TestMCPParser_TopLevelArray_NoMatch(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]`), + } + 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("MCP should remain nil for a top-level array, got %+v", pctx.Extensions.MCP) + } + if pctx.Extensions.Invocations != nil { + t.Errorf("expected no Invocation recorded, got %+v", pctx.Extensions.Invocations) + } +} + // 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/*,