-
Notifications
You must be signed in to change notification settings - Fork 446
Expand file tree
/
Copy pathvalidate_url_test.go
More file actions
260 lines (235 loc) · 10 KB
/
Copy pathvalidate_url_test.go
File metadata and controls
260 lines (235 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
package mcpgrafana
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateGrafanaURL(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
// Valid inputs.
{"http with host+port", "http://localhost:3000", false},
{"https with host", "https://grafana.example.com", false},
{"https with host and path", "https://grafana.example.com/subpath", false},
{"http with port and path", "http://host:8000/api/mcp", false},
// url.ParseRequestURI lowercases the scheme, so the scheme-allow-list
// check below (pu.Scheme != "http" && != "https") accepts uppercase
// input without needing a separate case-fold. Documented so a future
// refactor that adds a strings.ToLower doesn't accidentally break
// this invariant.
{"uppercase scheme normalized by ParseRequestURI", "HTTP://host", false},
// Trim behavior (H1 trim consolidation).
{"http with single trailing slash", "http://grafana.example/", false},
{"http with multiple trailing slashes", "http://grafana.example///", false},
// Invalid inputs.
{"empty string", "", true},
{"slash-only trims to empty", "/", true},
{"schemeless host rejected", "grafana.example.com", true},
{"schemeless host and port rejected", "grafana.example.com:3000", true},
{"plain text", "not a url", true},
{"invalid percent encoding", "http://%gg", true},
{"javascript scheme", "javascript:alert(1)", true},
{"file scheme", "file:///etc/passwd", true},
{"ftp scheme", "ftp://example.com", true},
{"scheme-relative", "//no-scheme.example.com", true},
{"relative path", "/relative/path", true},
{"http with empty host", "http://", true},
{"http with triple-slash and no host", "http:///path", true},
{"https with empty host", "https://", true},
{"control byte in URL", "http://host\x01", true},
// Embedded credentials rejected (issue #776).
{"embedded user:pass", "http://user:pass@host.example", true},
{"embedded user only", "http://user@host.example", true},
{"embedded user with https", "https://user:pass@host.example/path", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ValidateGrafanaURL(tc.input)
if tc.wantErr {
require.Error(t, got, "expected an error for input %q", tc.input)
assert.True(t, errors.Is(got, ErrInvalidGrafanaURL),
"error must wrap ErrInvalidGrafanaURL for input %q; got %v", tc.input, got)
} else {
assert.NoError(t, got, "expected no error for input %q", tc.input)
}
})
}
}
func TestValidateGrafanaURLMiddleware(t *testing.T) {
cases := []struct {
name string
setHeader bool
headerVal string
wantStatus int
wantCalled bool
}{
{"absent header passes through", false, "", http.StatusOK, true},
{"valid http header passes", true, "http://grafana.example", http.StatusOK, true},
{"valid https header passes", true, "https://grafana.example.com", http.StatusOK, true},
{"valid with trailing slash passes", true, "https://grafana.example.com/", http.StatusOK, true},
{"malformed percent encoding rejected", true, "http://%gg", http.StatusBadRequest, false},
{"javascript scheme rejected", true, "javascript:alert(1)", http.StatusBadRequest, false},
{"relative path rejected", true, "/relative", http.StatusBadRequest, false},
{"schemeless header rejected", true, "grafana.example.com", http.StatusBadRequest, false},
{"trim-to-empty slash rejected", true, "/", http.StatusBadRequest, false},
{"empty host rejected", true, "http://", http.StatusBadRequest, false},
{"CR-LF injection attempt rejected", true, "http://foo\r\nX-Injected: 1", http.StatusBadRequest, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tc.setHeader {
req.Header.Set(grafanaURLHeader, tc.headerVal)
}
rec := httptest.NewRecorder()
ValidateGrafanaURLMiddleware(next).ServeHTTP(rec, req)
assert.Equal(t, tc.wantStatus, rec.Code, "unexpected status code")
assert.Equal(t, tc.wantCalled, called, "next handler call state mismatch")
if tc.wantStatus == http.StatusBadRequest {
assert.Contains(t, rec.Body.String(), "invalid X-Grafana-URL",
"rejection body must include the operator-visible error signal")
}
})
}
t.Run("duplicate header - first value is validated", func(t *testing.T) {
// Header.Get returns the first value. A caller that sends two
// X-Grafana-URL headers gets validated on the first; the second is
// ignored by standard Go http header semantics. Documented so a
// future reader knows the behavior is intentional, not accidental.
//
// Use Header.Add (not the raw map) so the key is stored under its
// canonical form (textproto.CanonicalMIMEHeaderKey). Raw map
// assignment with the non-canonical "X-Grafana-URL" key would leave
// the header invisible to Header.Get (which canonicalizes its
// lookup), and this test would pass for the wrong reason.
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add(grafanaURLHeader, "http://ok.example")
req.Header.Add(grafanaURLHeader, "javascript:alert(1)")
rec := httptest.NewRecorder()
ValidateGrafanaURLMiddleware(next).ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, called)
})
t.Run("concurrent mixed under -race", func(t *testing.T) {
// Middleware is stateless by construction — no data race should
// surface. This test runs 20 concurrent requests through one
// middleware instance under go test -race to catch any accidental
// shared state introduced during review or future refactoring.
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mw := ValidateGrafanaURLMiddleware(next)
mixed := []struct {
header string
wantStatus int
}{
{"http://ok1.example", http.StatusOK},
{"http://%gg", http.StatusBadRequest},
{"https://ok2.example", http.StatusOK},
{"javascript:alert(1)", http.StatusBadRequest},
{"http://ok3.example:8000", http.StatusOK},
{"/", http.StatusBadRequest},
{"https://ok4.example/path", http.StatusOK},
{"file:///etc/passwd", http.StatusBadRequest},
{"http://ok5.example", http.StatusOK},
{"http://", http.StatusBadRequest},
}
var wg sync.WaitGroup
errs := make(chan string, 20)
for i := 0; i < 20; i++ {
tc := mixed[i%len(mixed)]
wg.Add(1)
go func(header string, wantStatus int) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(grafanaURLHeader, header)
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
if rec.Code != wantStatus {
errs <- "header " + header + ": got " + http.StatusText(rec.Code) + ", want " + http.StatusText(wantStatus)
}
}(tc.header, tc.wantStatus)
}
wg.Wait()
close(errs)
for e := range errs {
t.Error(e)
}
})
}
// Smoke coverage for ExtractGrafanaClientFromHeaders client construction.
// These tests exercise the extractor end-to-end through a real HTTP call.
func TestExtractGrafanaClientFromHeaders_IgnoresURLHeader(t *testing.T) {
t.Setenv("GRAFANA_SERVICE_ACCOUNT_TOKEN", "test-token")
var configuredHitCount int32
configuredServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&configuredHitCount, 1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"meta":{},"dashboard":{}}`))
}))
defer configuredServer.Close()
t.Setenv("GRAFANA_URL", configuredServer.URL)
var headerHitCount int32
headerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&headerHitCount, 1)
w.WriteHeader(http.StatusOK)
}))
defer headerServer.Close()
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
require.NoError(t, err)
req.Header.Set(grafanaURLHeader, headerServer.URL)
ctx := ExtractGrafanaClientFromHeaders(context.Background(), req)
c := GrafanaClientFromContext(ctx)
require.NotNil(t, c, "extractor must attach a client")
_, apiErr := c.Dashboards.GetDashboardByUID("any-uid")
// The call reaches the configured test server, which returns skeletal JSON. The
// openapi client may succeed or return a schema-mismatch error; either
// is acceptable. A URL-parse error would indicate invalid client wiring.
if apiErr != nil {
assert.NotContains(t, apiErr.Error(), "parse",
"configured URL must not produce a URL-parse error; got %v", apiErr)
}
assert.Greater(t, int(atomic.LoadInt32(&configuredHitCount)), 0,
"extractor must wire a client that reaches GRAFANA_URL")
assert.Zero(t, atomic.LoadInt32(&headerHitCount),
"extractor must ignore X-Grafana-URL when selecting the destination")
}
func TestExtractGrafanaClientFromHeaders_NoHeader(t *testing.T) {
// No X-Grafana-URL header: extractor falls back to env, and env is empty
// so defaultGrafanaURL (http://localhost:3000) applies. Nothing is
// listening on :3000 during tests, so the client call MUST fail with a
// connection-level error (proving defaultGrafanaURL was used) and MUST
// NOT fail with a URL-parse error (which would mean the extractor
// produced garbage).
t.Setenv("GRAFANA_URL", "")
t.Setenv("GRAFANA_SERVICE_ACCOUNT_TOKEN", "")
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaClientFromHeaders(context.Background(), req)
c := GrafanaClientFromContext(ctx)
require.NotNil(t, c, "extractor must attach a client even with no header")
_, apiErr := c.Dashboards.GetDashboardByUID("any-uid")
require.Error(t, apiErr,
"no-header path should fall back to defaultGrafanaURL and fail to connect")
assert.NotContains(t, apiErr.Error(), "parse",
"failure must be connection-level, not a URL-parse error; got %v", apiErr)
}