Skip to content

Commit dfa2b83

Browse files
rudycelekliDumbrisclaude
authored
fix(httpapi): percent-decode the secret name on DELETE /secrets/{name} (#1775)
## Problem Secret names aren't restricted. The reference parser accepts anything but `}` after the type, so `${keyring:openai:prod}` is a valid reference and `POST /api/v1/secrets` stores it as-is. The Web UI deletes with `/api/v1/secrets/${encodeURIComponent(name)}`, which sends `openai%3Aprod`. chi routes on `RawPath`, so `chi.URLParam(r, "name")` returns the encoded value and `handleDeleteSecret` asks the keyring to delete `openai%3Aprod`, an entry that doesn't exist. The real secret stays in the keyring. ## Fix Decode `{name}` with the existing `decodePathParam`, matching the other path-param handlers. ## Test `TestDeleteSecret_DecodesNameParam` registers a recording keyring provider and sends `DELETE /api/v1/secrets/openai%3Aprod`. On main the provider receives `openai%3Aprod`; with this change it receives `openai:prod`. `go test -count=1 ./internal/httpapi/` passes. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Algis Dumbris <a.dumbris@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5a8d9d9 commit dfa2b83

2 files changed

Lines changed: 88 additions & 0 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package httpapi
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/zap/zaptest"
12+
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
14+
)
15+
16+
// recordingKeyring is a keyring provider that records deleted names.
17+
type recordingKeyring struct{ deleted []string }
18+
19+
func (p *recordingKeyring) CanResolve(t string) bool { return t == secretTypeKeyring }
20+
func (p *recordingKeyring) Resolve(context.Context, secret.Ref) (string, error) {
21+
return "", nil
22+
}
23+
func (p *recordingKeyring) Store(context.Context, secret.Ref, string) error { return nil }
24+
func (p *recordingKeyring) Delete(_ context.Context, ref secret.Ref) error {
25+
p.deleted = append(p.deleted, ref.Name)
26+
return nil
27+
}
28+
func (p *recordingKeyring) List(context.Context) ([]secret.Ref, error) { return nil, nil }
29+
func (p *recordingKeyring) IsAvailable() bool { return true }
30+
31+
// Secret names may contain any character but '}' (${keyring:openai:prod} is a
32+
// valid reference). The Web UI encodes the name with encodeURIComponent and chi
33+
// routes on RawPath, so {name} arrives encoded and must be decoded before the
34+
// keyring delete, otherwise the wrong (nonexistent) entry is targeted.
35+
func TestDeleteSecret_DecodesNameParam(t *testing.T) {
36+
kr := &recordingKeyring{}
37+
res := secret.NewResolver()
38+
res.RegisterProvider(secretTypeKeyring, kr)
39+
ctrl := &secretResolverController{MockServerController: &MockServerController{}, resolver: res}
40+
srv := NewServer(ctrl, zaptest.NewLogger(t).Sugar(), nil)
41+
42+
req := httptest.NewRequest(http.MethodDelete, "/api/v1/secrets/openai%3Aprod?type=keyring", http.NoBody)
43+
req.Header.Set("X-API-Key", mockControllerAPIKey)
44+
w := httptest.NewRecorder()
45+
srv.ServeHTTP(w, req)
46+
47+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
48+
assert.Equal(t, []string{"openai:prod"}, kr.deleted)
49+
}
50+
51+
// A name whose only escape is a literal %XX (e.g. "a%41", sent by the Web UI as
52+
// "a%2541") leaves RawPath empty, so chi matches the already-decoded Path and
53+
// {name} must not be decoded a second time, or another entry ("aA") is deleted.
54+
func TestDeleteSecret_LiteralPercentNameNotDoubleDecoded(t *testing.T) {
55+
cases := map[string]string{
56+
"/api/v1/secrets/a%2541": "a%41",
57+
"/api/v1/secrets/k%253Ap": "k%3Ap",
58+
"/api/v1/secrets/a%252Fb": "a%2Fb",
59+
"/api/v1/secrets/openai%3Aprod": "openai:prod",
60+
"/api/v1/secrets/x%2Fy": "x/y",
61+
"/api/v1/secrets/100%25": "100%",
62+
"/api/v1/secrets/plain": "plain",
63+
}
64+
for path, want := range cases {
65+
t.Run(want, func(t *testing.T) {
66+
kr := &recordingKeyring{}
67+
res := secret.NewResolver()
68+
res.RegisterProvider(secretTypeKeyring, kr)
69+
ctrl := &secretResolverController{MockServerController: &MockServerController{}, resolver: res}
70+
srv := NewServer(ctrl, zaptest.NewLogger(t).Sugar(), nil)
71+
72+
req := httptest.NewRequest(http.MethodDelete, path+"?type=keyring", http.NoBody)
73+
req.Header.Set("X-API-Key", mockControllerAPIKey)
74+
w := httptest.NewRecorder()
75+
srv.ServeHTTP(w, req)
76+
77+
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
78+
assert.Equal(t, []string{want}, kr.deleted)
79+
})
80+
}
81+
}

‎internal/httpapi/server.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5063,7 +5063,14 @@ func (s *Server) handleDeleteSecret(w http.ResponseWriter, r *http.Request) {
50635063
return
50645064
}
50655065

5066+
// chi routes on RawPath only when Go set it (the client's encoding differs
5067+
// from the default one); then {name} is still encoded and must be decoded
5068+
// once. Otherwise chi already matched the decoded Path, and decoding again
5069+
// would turn a literal "a%41" into "aA" and delete the wrong entry.
50665070
name := chi.URLParam(r, "name")
5071+
if r.URL.RawPath != "" {
5072+
name = decodePathParam(name)
5073+
}
50675074
if name == "" {
50685075
s.writeError(w, r, http.StatusBadRequest, "Secret name is required")
50695076
return

0 commit comments

Comments
 (0)