1package mcp
2
3import (
4 "net/url"
5 "os"
6 "regexp"
7 "sort"
8 "strings"
9 "unicode/utf8"
10)
11
12// This file holds the diagnostics boundary: everything that originates from a
13// server (stderr, HTTP error bodies, JSON-RPC error messages) or from the HTTP
14// client (url.Error carries the full request URL) is scrubbed here before it
15// reaches an error value, the terminal, or a log.
16
17const (
18 redactedMarker = "[redacted]"
19 // minSecretLen keeps one- and two-character configuration values (flags
20 // such as "1") from turning every diagnostic into noise.
21 minSecretLen = 4
22)
23
24// redactor removes configured secret values from server-provided text.
25// A nil *redactor still strips URLs' credentials and control characters.
26type redactor struct {
27 secrets []string
28 // maxLen is the length of the longest secret, used to size the overlap
29 // when long lines are cut for display.
30 maxLen int
31}
32
33// newRedactor collects the secret-bearing values of one server configuration:
34// env and header values, the bearer token, values passed through from the host
35// environment, and credentials embedded in the URL.
36func newRedactor(cfg ServerConfig) *redactor {
37 set := map[string]bool{}
38 add := func(v string) {
39 v = strings.TrimSpace(v)
40 if len(v) < minSecretLen {
41 return
42 }
43 set[v] = true
44 // The control-stripped form too: text is sanitized before it is
45 // matched, so a secret that itself contains escape bytes must match
46 // in its sanitized shape.
47 if clean := sanitizeText(v, true); len(clean) >= minSecretLen {
48 set[clean] = true
49 }
50 // "Bearer abc123" is also worth redacting as just "abc123".
51 if strings.ContainsAny(v, " \t") {
52 for _, field := range strings.Fields(v) {
53 if len(field) >= 8 {
54 set[field] = true
55 }
56 }
57 }
58 }
59 for _, v := range cfg.Env {
60 add(v)
61 }
62 for _, v := range cfg.Headers {
63 add(v)
64 }
65 for _, name := range cfg.EnvHeaders {
66 if v, ok := os.LookupEnv(name); ok {
67 add(v)
68 }
69 }
70 for _, name := range cfg.EnvVars {
71 if v, ok := os.LookupEnv(name); ok {
72 add(v)
73 }
74 }
75 if cfg.BearerTokenEnvVar != "" {
76 if v, ok := os.LookupEnv(cfg.BearerTokenEnvVar); ok {
77 add(v)
78 }
79 }
80 if cfg.URL != "" {
81 for _, v := range urlSecrets(cfg.URL) {
82 add(v)
83 }
84 }
85 r := &redactor{}
86 for v := range set {
87 r.secrets = append(r.secrets, v)
88 r.maxLen = max(r.maxLen, len(v))
89 }
90 // Longest first so a secret containing another is replaced whole.
91 sort.Slice(r.secrets, func(i, j int) bool {
92 if len(r.secrets[i]) != len(r.secrets[j]) {
93 return len(r.secrets[i]) > len(r.secrets[j])
94 }
95 return r.secrets[i] < r.secrets[j]
96 })
97 return r
98}
99
100// urlSecrets returns the secret-bearing parts of raw: userinfo (user and
101// password) and query values, both as written and percent-decoded. It splits
102// the text itself instead of requiring url.Parse to succeed, because the URLs
103// that matter most here are the malformed ones that fail to parse.
104func urlSecrets(raw string) []string {
105 var out []string
106 add := func(v string) {
107 if v == "" {
108 return
109 }
110 out = append(out, v)
111 if d, err := url.PathUnescape(v); err == nil && d != v {
112 out = append(out, d)
113 }
114 if d, err := url.QueryUnescape(v); err == nil && d != v {
115 out = append(out, d)
116 }
117 }
118 rest := raw
119 if i := strings.Index(rest, "://"); i >= 0 {
120 rest = rest[i+3:]
121 }
122 authority := rest
123 if i := strings.IndexAny(authority, "/?#"); i >= 0 {
124 authority = authority[:i]
125 }
126 if i := strings.LastIndex(authority, "@"); i >= 0 {
127 user, pass, _ := strings.Cut(authority[:i], ":")
128 add(user)
129 add(pass)
130 }
131 if i := strings.Index(rest, "?"); i >= 0 {
132 query := rest[i+1:]
133 if j := strings.Index(query, "#"); j >= 0 {
134 query = query[:j]
135 }
136 for _, pair := range strings.FieldsFunc(query, func(r rune) bool { return r == '&' || r == ';' }) {
137 if _, v, ok := strings.Cut(pair, "="); ok {
138 add(v)
139 }
140 }
141 if values, err := url.ParseQuery(query); err == nil {
142 for _, vs := range values {
143 for _, v := range vs {
144 add(v)
145 }
146 }
147 }
148 }
149 return out
150}
151
152// scrub redacts configured secrets, strips credentials and query strings from
153// URLs, and removes terminal control sequences. Newlines are kept.
154func (r *redactor) scrub(s string) string {
155 if s == "" {
156 return s
157 }
158 // Redact, strip control sequences, then redact again: a secret that has
159 // escape bytes inserted into it only becomes matchable once they are gone.
160 s = r.redact(s)
161 s = sanitizeText(s, true)
162 s = r.redact(s)
163 return scrubURLs(s)
164}
165
166// overlap is how many extra bytes to read past a display limit so a secret
167// straddling the limit is captured whole. It is bounded by maxStderrOverlap.
168func (r *redactor) overlap() int {
169 if r == nil {
170 return 0
171 }
172 return min(r.maxLen, maxStderrOverlap)
173}
174
175// redact replaces configured secret values. A nil redactor changes nothing.
176func (r *redactor) redact(s string) string {
177 if r == nil {
178 return s
179 }
180 for _, secret := range r.secrets {
181 s = strings.ReplaceAll(s, secret, redactedMarker)
182 }
183 return s
184}
185
186// trimPartialSecret removes a trailing fragment that is a proper prefix of a
187// secret. It is for text that was cut at an arbitrary byte: a secret that
188// straddles the cut cannot be matched as a whole, and its prefix must not
189// survive either.
190func (r *redactor) trimPartialSecret(s string) string {
191 if r == nil {
192 return s
193 }
194 drop := 0
195 for _, secret := range r.secrets {
196 for k := min(len(secret)-1, len(s)); k > drop; k-- {
197 if strings.HasSuffix(s, secret[:k]) {
198 drop = k
199 break
200 }
201 }
202 }
203 return s[:len(s)-drop]
204}
205
206// scrubOneLine is scrub with newlines collapsed to spaces.
207func (r *redactor) scrubOneLine(s string) string {
208 return sanitizeText(r.scrub(s), false)
209}
210
211var urlInText = regexp.MustCompile("(?i)\\b[a-z][a-z0-9+.-]*://[^\\s\"'<>`]+")
212
213// scrubURLs rewrites every URL in s to scheme://host/path, dropping userinfo,
214// query, and fragment.
215func scrubURLs(s string) string {
216 if !strings.Contains(s, "://") {
217 return s
218 }
219 return urlInText.ReplaceAllStringFunc(s, stripURL)
220}
221
222func stripURL(raw string) string {
223 schemeEnd := strings.Index(raw, "://")
224 if schemeEnd < 0 {
225 return raw
226 }
227 scheme, rest := raw[:schemeEnd+3], raw[schemeEnd+3:]
228 end := len(rest)
229 if i := strings.IndexAny(rest, "/?#"); i >= 0 {
230 end = i
231 }
232 authority, tail := rest[:end], rest[end:]
233 if i := strings.LastIndex(authority, "@"); i >= 0 {
234 authority = authority[i+1:]
235 }
236 if i := strings.IndexAny(tail, "?#"); i >= 0 {
237 tail = tail[:i]
238 }
239 return scheme + authority + tail
240}
241
242// scrubbedError carries a sanitized message while keeping the original error
243// reachable through errors.Is/As.
244type scrubbedError struct {
245 msg string
246 err error
247}
248
249func (e *scrubbedError) Error() string { return e.msg }
250func (e *scrubbedError) Unwrap() error { return e.err }
251
252// scrubError returns err with a scrubbed message, or nil for nil. Context
253// errors are returned unchanged so callers keep comparing them directly.
254func (r *redactor) scrubError(err error) error {
255 if err == nil {
256 return nil
257 }
258 msg := r.scrub(err.Error())
259 if msg == err.Error() {
260 return err
261 }
262 return &scrubbedError{msg: msg, err: err}
263}
264
265// sanitizeText removes terminal control sequences (CSI, OSC, DCS/APC/PM/SOS
266// strings, other escapes), C0/C1 control characters, and bidirectional
267// overrides. Tabs become spaces. Newlines are kept when keepNewlines is true
268// and become spaces otherwise; carriage returns are dropped.
269func sanitizeText(s string, keepNewlines bool) string {
270 clean := true
271 for i := 0; i < len(s); i++ {
272 c := s[i]
273 if c < 0x20 && c != '\n' || c == 0x7f || c >= 0x80 || (c == '\n' && !keepNewlines) {
274 clean = false
275 break
276 }
277 }
278 if clean {
279 return s
280 }
281 var b strings.Builder
282 b.Grow(len(s))
283 for i := 0; i < len(s); {
284 r, size := utf8.DecodeRuneInString(s[i:])
285 i += size
286 switch {
287 case r == 0x1b:
288 i = skipEscape(s, i)
289 case r == 0x9b:
290 i = skipCSI(s, i)
291 case r == 0x9d || r == 0x90 || r == 0x98 || r == 0x9e || r == 0x9f:
292 i = skipControlString(s, i)
293 case r == '\n':
294 if keepNewlines {
295 b.WriteByte('\n')
296 } else {
297 b.WriteByte(' ')
298 }
299 case r == '\t':
300 b.WriteByte(' ')
301 case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
302 // dropped (includes \r)
303 case (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) || r == 0x200e || r == 0x200f:
304 // bidirectional controls can reorder terminal text
305 default:
306 b.WriteRune(r)
307 }
308 }
309 return b.String()
310}
311
312// skipEscape returns the index after the escape sequence whose ESC byte was
313// just consumed (i points at the byte after ESC).
314func skipEscape(s string, i int) int {
315 if i >= len(s) {
316 return i
317 }
318 switch c := s[i]; {
319 case c == '[':
320 return skipCSI(s, i+1)
321 case c == ']' || c == 'P' || c == 'X' || c == '^' || c == '_':
322 return skipControlString(s, i+1)
323 case c >= 0x20 && c <= 0x2f:
324 for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
325 i++
326 }
327 if i < len(s) {
328 i++ // final byte
329 }
330 return i
331 default:
332 return i + 1
333 }
334}
335
336func skipCSI(s string, i int) int {
337 for i < len(s) && s[i] >= 0x30 && s[i] <= 0x3f {
338 i++
339 }
340 for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
341 i++
342 }
343 if i < len(s) && s[i] >= 0x40 && s[i] <= 0x7e {
344 i++
345 }
346 return i
347}
348
349// skipControlString consumes an OSC/DCS/APC/PM/SOS payload up to and
350// including its terminator (BEL, ESC \, or C1 ST). An ESC that does not start
351// ST cancels the string without being consumed.
352func skipControlString(s string, i int) int {
353 for i < len(s) {
354 switch {
355 case s[i] == 0x07:
356 return i + 1
357 case s[i] == 0x1b:
358 if i+1 < len(s) && s[i+1] == '\\' {
359 return i + 2
360 }
361 return i
362 case s[i] == 0xc2 && i+1 < len(s) && s[i+1] == 0x9c:
363 return i + 2
364 }
365 i++
366 }
367 return i
368}