Skip to content

Commit caf4095

Browse files
authored
fix: encode Basic Auth per RFC 7617 in proxied_client.go (#758)
1 parent 3cd6924 commit caf4095

2 files changed

Lines changed: 84 additions & 2 deletions

File tree

proxied_client.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/base64"
66
"fmt"
77
"log/slog"
8+
"net/url"
89
"sync"
910

1011
mcp_client "github.com/mark3labs/mcp-go/client"
@@ -22,6 +23,17 @@ type ProxiedClient struct {
2223
mutex sync.RWMutex
2324
}
2425

26+
// basicAuthHeader encodes credentials per RFC 7617:
27+
// base64(username ":" password) with raw bytes, matching
28+
// (*http.Request).SetBasicAuth in the standard library. url.Userinfo.String()
29+
// percent-escapes reserved characters per RFC 3986, which is wrong here —
30+
// passwords containing ':', '@', '/', '%', or space would be double-encoded
31+
// on the wire and rejected by spec-compliant Basic Auth parsers.
32+
func basicAuthHeader(u *url.Userinfo) string {
33+
password, _ := u.Password()
34+
return "Basic " + base64.StdEncoding.EncodeToString([]byte(u.Username()+":"+password))
35+
}
36+
2537
// NewProxiedClient creates a new connection to a remote MCP server
2638
func NewProxiedClient(ctx context.Context, datasourceUID, datasourceName, datasourceType, mcpEndpoint string) (*ProxiedClient, error) {
2739
// Get Grafana config for authentication
@@ -32,8 +44,7 @@ func NewProxiedClient(ctx context.Context, datasourceUID, datasourceName, dataso
3244
if config.APIKey != "" {
3345
headers["Authorization"] = "Bearer " + config.APIKey
3446
} else if config.BasicAuth != nil {
35-
auth := config.BasicAuth.String()
36-
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
47+
headers["Authorization"] = basicAuthHeader(config.BasicAuth)
3748
}
3849

3950
// Add org ID header if configured

proxied_client_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package mcpgrafana
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
// TestBasicAuthHeaderEncoding verifies that basicAuthHeader produces the
11+
// same bytes as (*http.Request).SetBasicAuth — base64(username ":" password)
12+
// with raw bytes per RFC 7617. Regression test for the bug where
13+
// url.Userinfo.String() was used, which percent-escapes reserved characters
14+
// per RFC 3986 and breaks Basic Auth for passwords containing ':', '@',
15+
// '/', '%', or space.
16+
func TestBasicAuthHeaderEncoding(t *testing.T) {
17+
cases := []struct {
18+
name, username, password, want string
19+
}{
20+
{
21+
name: "plain ASCII",
22+
username: "admin",
23+
password: "password",
24+
want: "Basic YWRtaW46cGFzc3dvcmQ=",
25+
},
26+
{
27+
name: "colon in password",
28+
username: "admin",
29+
password: "p:w0rd",
30+
want: "Basic YWRtaW46cDp3MHJk",
31+
},
32+
{
33+
name: "at sign in password",
34+
username: "admin",
35+
password: "p@ssw0rd",
36+
want: "Basic YWRtaW46cEBzc3cwcmQ=",
37+
},
38+
{
39+
name: "percent in password",
40+
username: "admin",
41+
password: "p%w0rd",
42+
want: "Basic YWRtaW46cCV3MHJk",
43+
},
44+
{
45+
name: "space in password",
46+
username: "admin",
47+
password: "pw 0rd",
48+
want: "Basic YWRtaW46cHcgMHJk",
49+
},
50+
{
51+
name: "slash in password",
52+
username: "admin",
53+
password: "p/w0rd",
54+
want: "Basic YWRtaW46cC93MHJk",
55+
},
56+
{
57+
name: "empty password",
58+
username: "admin",
59+
password: "",
60+
want: "Basic YWRtaW46",
61+
},
62+
}
63+
for _, tc := range cases {
64+
t.Run(tc.name, func(t *testing.T) {
65+
u := url.UserPassword(tc.username, tc.password)
66+
got := basicAuthHeader(u)
67+
assert.Equal(t, tc.want, got,
68+
"basicAuthHeader must produce raw base64(user:password) per RFC 7617, not percent-encoded userinfo")
69+
})
70+
}
71+
}

0 commit comments

Comments
 (0)