Skip to content

Commit b05d78e

Browse files
fix(httpapi): percent-decode the {tool} path param before the approval lookup (#1750)
# Pull Request ## Description chi routes on `RawPath`, so path params arrive percent-encoded whenever the client's encoding differs from Go's default. MCP-1118 fixed this for `{id}` with `decodeServerIDParam` on the `/servers/{id}` subtree, but the nested `{tool}` param is still read raw by its two handlers: - `GET /api/v1/servers/{id}/tools/{tool}/diff` (`handleGetToolDiff`) - `POST /api/v1/servers/{id}/tools/{tool}/enabled` (`handleSetToolEnabled`) **Trigger.** The Web UI builds both URLs with `encodeURIComponent(toolName)` (`frontend/src/services/api.ts:577`, `:618`), which encodes `:` as `%3A` and `/` as `%2F`. Go's default path encoding leaves `:` alone, so for a tool named `ns:erase` the request has a non-empty `RawPath`, chi hands the handler `ns%3Aerase`, and the exact-match approval lookup misses. The Review diff for that tool never loads and the per-tool enable/disable toggle fails. Tool names carrying `:` or `/` are upstream-defined and already accounted for elsewhere (see `canonicalIdentity` in `internal/profile/glob.go`). `decodeServerIDParam` cannot cover `{tool}`: it runs as subtree middleware, before the nested route has been matched and the param populated. So the fix is the same `decodePathParam` call in the two handlers, as `handleAddFromRegistry` already does for `{serverId}`. ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass `TestToolSubresource_EncodedToolNameUnescaped` (`internal/httpapi/tool_path_param_decode_test.go`) drives both routes through `Server.ServeHTTP` with `ns%3Aerase` and `ns%2Ferase` and asserts the decoded name reaches the lookup. ``` CGO_ENABLED=0 go test ./internal/httpapi -count=1 # ok # with server.go reverted: CGO_ENABLED=0 go test ./internal/httpapi -run TestToolSubresource -count=1 # FAIL, all 4 subtests gofmt -l <changed files> # clean ``` (`CGO_ENABLED=0` only because my local macOS SDK cannot link cgo test binaries; nothing here uses cgo.) ## Note on the CodeQL alert CodeQL reports `go/reflected-xss` at `internal/httpapi/middleware.go:85` (`statusRecorder.Write`) because the decoded tool name now reaches the response. The only sinks are `writeSuccess` / `writeError`, both of which go through `writeJSON`: `Content-Type: application/json` plus `json.Encoder` with its default HTML escaping (`<`, `>`, `&` become `\u003c` and so on). The raw `chi.URLParam` value already reached the same sinks before this change. The decode only turns `%3A` into `:` so the controller lookup matches. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 98e568b commit b05d78e

2 files changed

Lines changed: 69 additions & 2 deletions

File tree

‎internal/httpapi/server.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7275,7 +7275,7 @@ func (s *Server) handleSetToolEnabled(w http.ResponseWriter, r *http.Request) {
72757275
}
72767276

72777277
serverID := chi.URLParam(r, "id")
7278-
toolName := chi.URLParam(r, "tool")
7278+
toolName := decodePathParam(chi.URLParam(r, "tool"))
72797279
if serverID == "" || toolName == "" {
72807280
s.writeError(w, r, http.StatusBadRequest, "Server ID and tool name required")
72817281
return
@@ -7402,7 +7402,7 @@ func (s *Server) handleSetAllToolsEnabled(enabled bool) http.HandlerFunc {
74027402

74037403
func (s *Server) handleGetToolDiff(w http.ResponseWriter, r *http.Request) {
74047404
serverID := chi.URLParam(r, "id")
7405-
toolName := chi.URLParam(r, "tool")
7405+
toolName := decodePathParam(chi.URLParam(r, "tool"))
74067406

74077407
if serverID == "" || toolName == "" {
74087408
s.writeError(w, r, http.StatusBadRequest, "Server ID and tool name required")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package httpapi
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"go.uber.org/zap"
13+
14+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
15+
)
16+
17+
// TestToolSubresource_EncodedToolNameUnescaped: chi routes on RawPath, so the
18+
// {tool} param arrives percent-encoded whenever the encoding differs from Go's
19+
// default (the Web UI sends encodeURIComponent(toolName), which encodes ':' and
20+
// '/'). Tool names such as "ns:erase" or "ns/erase" must be decoded before the
21+
// exact-match approval lookup, the same way {id} is (MCP-1118).
22+
func TestToolSubresource_EncodedToolNameUnescaped(t *testing.T) {
23+
for _, tc := range []struct{ encoded, decoded string }{
24+
{"ns%3Aerase", "ns:erase"},
25+
{"ns%2Ferase", "ns/erase"},
26+
} {
27+
newCtrl := func() *mockToolQuarantineController {
28+
return &mockToolQuarantineController{
29+
apiKey: "test-key",
30+
approvals: []*storage.ToolApprovalRecord{{
31+
ServerName: "github",
32+
ToolName: tc.decoded,
33+
Status: storage.ToolApprovalStatusChanged,
34+
PreviousDescription: "old",
35+
CurrentDescription: "new",
36+
}},
37+
}
38+
}
39+
40+
t.Run("GET diff "+tc.decoded, func(t *testing.T) {
41+
server := NewServer(newCtrl(), zap.NewNop().Sugar(), nil)
42+
req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/github/tools/"+tc.encoded+"/diff", nil)
43+
req.Header.Set("X-API-Key", "test-key")
44+
w := httptest.NewRecorder()
45+
server.ServeHTTP(w, req)
46+
47+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
48+
var resp map[string]interface{}
49+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
50+
data := resp["data"].(map[string]interface{})
51+
assert.Equal(t, "new", data["current_description"])
52+
})
53+
54+
t.Run("POST enabled "+tc.decoded, func(t *testing.T) {
55+
ctrl := newCtrl()
56+
server := NewServer(ctrl, zap.NewNop().Sugar(), nil)
57+
req := httptest.NewRequest(http.MethodPost, "/api/v1/servers/github/tools/"+tc.encoded+"/enabled",
58+
bytes.NewBufferString(`{"enabled":false}`))
59+
req.Header.Set("X-API-Key", "test-key")
60+
w := httptest.NewRecorder()
61+
server.ServeHTTP(w, req)
62+
63+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
64+
assert.Equal(t, tc.decoded, ctrl.setToolEnabledTool)
65+
})
66+
}
67+
}

0 commit comments

Comments
 (0)