diff --git a/cmd/mcp/logger.go b/cmd/mcp/logger.go index 688939d..bbd8a2f 100644 --- a/cmd/mcp/logger.go +++ b/cmd/mcp/logger.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "strings" "time" ) @@ -86,8 +87,35 @@ func (r *statusRecorder) WriteHeader(code int) { r.ResponseWriter.WriteHeader(code) } +// SafeLogPath returns a redacted version of path that omits sensitive tokens. +// +// In route-token mode the raw token in the URL prefix is replaced with +// {redacted}. In multi-tenant mode the per-user API key that occupies the +// first path segment is replaced with {tenant}. Plain /mcp paths are returned +// unchanged. +func SafeLogPath(path, routeToken string, multiTenant bool) string { + if routeToken != "" { + prefix := "/" + routeToken + if strings.HasPrefix(path, prefix+"/") { + return "/{redacted}" + path[len(prefix):] + } + if path == prefix { + return "/{redacted}" + } + } + if multiTenant { + trimmed := strings.TrimPrefix(path, "/") + idx := strings.Index(trimmed, "/") + if idx > 0 { + return "/{tenant}" + trimmed[idx:] + } + } + return path +} + // httpLoggingMiddleware logs every HTTP request at Info level (Warn for 4xx/5xx). -func httpLoggingMiddleware(next http.Handler) http.Handler { +// routeToken and multiTenant are used to redact sensitive tokens from the logged path. +func httpLoggingMiddleware(next http.Handler, routeToken string, multiTenant bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} @@ -96,7 +124,7 @@ func httpLoggingMiddleware(next http.Handler) http.Handler { args := []any{ "method", r.Method, - "path", r.URL.Path, + "path", SafeLogPath(r.URL.Path, routeToken, multiTenant), "remote_addr", r.RemoteAddr, "status", rec.status, "duration_ms", duration.Milliseconds(), diff --git a/cmd/mcp/serve.go b/cmd/mcp/serve.go index a5c79d1..3e042d2 100644 --- a/cmd/mcp/serve.go +++ b/cmd/mcp/serve.go @@ -157,7 +157,7 @@ func runServe(_ *cobra.Command, args []string) error { } else { handler = httpHandler } - handler = httpLoggingMiddleware(handler) + handler = httpLoggingMiddleware(handler, routeToken, multiTenant) if authToken != "" { handler = bearerAuthMiddleware(authToken, handler) } diff --git a/tests/mcp_logger_test.go b/tests/mcp_logger_test.go new file mode 100644 index 0000000..e2ee3f5 --- /dev/null +++ b/tests/mcp_logger_test.go @@ -0,0 +1,95 @@ +package tests + +import ( + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSafeLogPath(t *testing.T) { + tests := []struct { + name string + path string + routeToken string + multiTenant bool + want string + }{ + { + name: "plain mcp path unchanged", + path: "/mcp", + want: "/mcp", + }, + { + name: "plain mcp sse path unchanged", + path: "/mcp/sse", + want: "/mcp/sse", + }, + { + name: "route token in prefix is redacted", + path: "/abc123/mcp", + routeToken: "abc123", + want: "/{redacted}/mcp", + }, + { + name: "route token sse path is redacted", + path: "/abc123/mcp/sse", + routeToken: "abc123", + want: "/{redacted}/mcp/sse", + }, + { + name: "route token exact match is redacted", + path: "/abc123", + routeToken: "abc123", + want: "/{redacted}", + }, + { + name: "unrelated path is unchanged in route token mode", + path: "/health", + routeToken: "abc123", + want: "/health", + }, + { + name: "multi-tenant api key in path is redacted", + path: "/user-api-key/mcp", + multiTenant: true, + want: "/{tenant}/mcp", + }, + { + name: "multi-tenant api key with sse suffix is redacted", + path: "/user-api-key/mcp/sse", + multiTenant: true, + want: "/{tenant}/mcp/sse", + }, + { + name: "root path unchanged in multi-tenant mode", + path: "/", + multiTenant: true, + want: "/", + }, + { + name: "single segment path unchanged in multi-tenant mode", + path: "/health", + multiTenant: true, + want: "/health", + }, + { + name: "no route token and no multi-tenant returns path unchanged", + path: "/unexpected/path", + routeToken: "", + multiTenant: false, + want: "/unexpected/path", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := cmdmcp.SafeLogPath(tc.path, tc.routeToken, tc.multiTenant) + assert.Equal(t, tc.want, got) + // Verify the raw token never appears in the output. + if tc.routeToken != "" { + assert.NotContains(t, got, tc.routeToken) + } + }) + } +}