Skip to content

Commit 8b7b156

Browse files
authored
fix(logs): sanitize per-server log filename for namespaced registry names (MCP-1111) (#604)
* fix(logs): sanitize per-server log filename for namespaced names Official modelcontextprotocol/registry server names are namespace/name (e.g. io.github.evidai/polymarket-guard). The per-server log filename is derived from the server name, so an unsanitized "/" turned server-<ns>/<name>.log into a nested directory instead of a single flat log file. Add serverLogFilename/sanitizeServerLogName and route all three derivation sites (CreateUpstreamServerLogger, CreateCLIUpstreamServerLogger, ReadUpstreamServerLogTail) through it so writes and the tail reader agree on one flat path. Characters outside [A-Za-z0-9._-] become "_". The companion registry-add percent-decode is already on main (decodePathParam in internal/httpapi/server.go, PR #591 / MCP-1056, covered by TestAddFromRegistry_SlashServerIDUnescaped), so this change completes the remaining log-path half. Related #598 * test(logs): avoid t.TempDir cleanup race on Windows for open log handle The lumberjack writer keeps the per-server log file open for the logger's lifetime, and Windows cannot remove an open file. t.TempDir's cleanup asserts RemoveAll succeeds, failing the Cross-Platform Logging Tests job on windows-latest. Switch to os.MkdirTemp with a best-effort cleanup (mirroring TestE2E_LogRotation) and stop asserting on Sync(). Related #598 * fix(logs): route daemon + CLI log reads through sanitized filename helper Addresses Codex review on PR #604: the writer sanitized per-server log filenames but two READ sites still derived the raw server-%s.log path, so GET /api/v1/servers/{id}/logs and no-daemon CLI reads still 404'd for namespace/name registry servers. Export ServerLogFilename and route both read sites through it; tie the read-path filename to the writer's flat file in the regression test. Related #598 * fix(cliclient): percent-encode server name in daemon logs URL Third read site for the namespaced-name log fix (Codex review round 2 on PR #604): the daemon-backed CLI logs client built /servers/%s/logs with the raw name, so 'mcpproxy upstream logs io.github.x/name' missed the chi route. PathEscape the name (matching the registry client) + regression test. Related #598 * fix(httpapi): decode percent-encoded server id in handleGetServerLogs Closes the daemon server-side half of the namespaced-name log fix (Codex review round 3 on PR #604): the client now encodes the name, but the handler read chi.URLParam("id") raw, so %2F reached the lookup un-decoded. Route it through decodePathParam (matching handleAddFromRegistry) + router regression test proving /servers/io.github.evidai%2Fpolymarket-guard/logs decodes. Related #598 * fix(logs): contain server-name-derived log path within log dir CodeQL go/path-injection flagged the daemon log reader (internal/server/server.go) where a user-controlled server name flows into the log file path. logs.ServerLogFilename already sanitizes the name to a single path element, so the flow is safe, but CodeQL does not recognize the strings.Map char-whitelist as a barrier. Add an explicit filepath.Clean + prefix containment guard at both log-read sites (daemon reader and the no-daemon CLI reader, which feeds tail) so the resolved path can never escape the log directory. Defense-in-depth + clears the alert without weakening any check. Valid namespaced names (io.github.owner/repo) are unaffected — their sanitized filename stays a single element under logDir. Related #604
1 parent 6c6736c commit 8b7b156

10 files changed

Lines changed: 246 additions & 10 deletions

File tree

cmd/mcpproxy/upstream_cmd.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,15 @@ func runUpstreamLogsFromFile(globalConfig *config.Config, serverName string) err
772772
}
773773
}
774774

775-
logFile := filepath.Join(logDir, fmt.Sprintf("server-%s.log", serverName))
775+
logFile := filepath.Join(logDir, logs.ServerLogFilename(serverName))
776+
777+
// Defense-in-depth: logs.ServerLogFilename already sanitizes the (user-controlled)
778+
// server name to a single path element, but verify the resolved path stays inside
779+
// logDir before it reaches os.Stat/tail so a crafted name can never escape the log
780+
// directory (path-injection barrier).
781+
if !strings.HasPrefix(filepath.Clean(logFile), filepath.Clean(logDir)+string(os.PathSeparator)) {
782+
return fmt.Errorf("invalid server name: %s", serverName)
783+
}
776784

777785
// Check if file exists
778786
if _, err := os.Stat(logFile); os.IsNotExist(err) {

docs/cli/management-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,4 @@ CLI commands automatically detect and use Unix socket/named pipe communication w
165165

166166
Files:
167167
- `main.log` - Main application log
168-
- `server-{name}.log` - Per-server logs
168+
- `server-{name}.log` - Per-server logs (reserved characters in `{name}`, e.g. the `/` in registry names, are sanitized to `_`)

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ See [Setup Guide - HTTPS](setup.md#optional-https-setup) for complete details.
491491
- **macOS:** `~/Library/Logs/mcpproxy/main.log`
492492
- **Linux:** `~/.local/state/mcpproxy/logs/main.log` (or `/var/log/mcpproxy` when running as root)
493493
- **Windows:** `%LOCALAPPDATA%\mcpproxy\logs\main.log`
494-
- **Per-server logs:** same directory, `server-{name}.log`
494+
- **Per-server logs:** same directory, `server-{name}.log` (characters in the server name that aren't letters, digits, `.`, `-`, or `_` — such as the `/` in registry names like `io.github.evidai/polymarket-guard` — are sanitized to `_`, so the log is always a single flat file)
495495
- **Custom:** set `log_dir` to override (supports `~` expansion)
496496

497497
**Behavior notes:**

internal/cliclient/client.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,11 @@ func (c *Client) GetServers(ctx context.Context) ([]map[string]interface{}, erro
415415

416416
// GetServerLogs retrieves logs for a specific server.
417417
func (c *Client) GetServerLogs(ctx context.Context, serverName string, tail int) ([]contracts.LogEntry, error) {
418-
url := fmt.Sprintf("%s/api/v1/servers/%s/logs?tail=%d", c.baseURL, serverName, tail)
419-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
418+
// PathEscape the server name: official-registry names are namespace/name and
419+
// contain "/", which would otherwise inject extra path segments and miss the
420+
// chi /servers/{id}/logs route (MCP-1111 / #598).
421+
reqURL := fmt.Sprintf("%s/api/v1/servers/%s/logs?tail=%d", c.baseURL, url.PathEscape(serverName), tail)
422+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
420423
if err != nil {
421424
return nil, fmt.Errorf("failed to create request: %w", err)
422425
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package cliclient
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"go.uber.org/zap"
14+
)
15+
16+
// Regression for MCP-1111 / #598: the daemon logs client must percent-encode a
17+
// namespaced (slash-bearing) official-registry server name so the request matches
18+
// the chi /servers/{id}/logs route instead of injecting extra path segments.
19+
func TestClient_GetServerLogs_EscapesSlashName(t *testing.T) {
20+
const serverName = "io.github.evidai/polymarket-guard"
21+
var gotEscaped string
22+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
gotEscaped = r.URL.EscapedPath()
24+
w.Header().Set("Content-Type", "application/json")
25+
w.WriteHeader(http.StatusOK)
26+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
27+
"success": true,
28+
"data": map[string]interface{}{"logs": []interface{}{}},
29+
})
30+
}))
31+
defer ts.Close()
32+
33+
client := NewClient(ts.URL, zap.NewNop().Sugar())
34+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
35+
defer cancel()
36+
37+
_, err := client.GetServerLogs(ctx, serverName, 50)
38+
require.NoError(t, err)
39+
assert.Equal(t, "/api/v1/servers/io.github.evidai%2Fpolymarket-guard/logs", gotEscaped,
40+
"server name must be percent-encoded in the daemon logs URL")
41+
}

internal/httpapi/server.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2678,7 +2678,10 @@ func (s *Server) handleGetGlobalTools(w http.ResponseWriter, r *http.Request) {
26782678
// @Failure 500 {object} contracts.ErrorResponse "Internal server error"
26792679
// @Router /api/v1/servers/{id}/logs [get]
26802680
func (s *Server) handleGetServerLogs(w http.ResponseWriter, r *http.Request) {
2681-
serverID := chi.URLParam(r, "id")
2681+
// chi routes on RawPath, so a namespace/name server id (e.g.
2682+
// io.github.evidai/polymarket-guard) arrives percent-encoded. Decode it
2683+
// before lookup, matching handleAddFromRegistry (MCP-1111 / #598).
2684+
serverID := decodePathParam(chi.URLParam(r, "id"))
26822685
if serverID == "" {
26832686
s.writeError(w, r, http.StatusBadRequest, "Server ID required")
26842687
return
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package httpapi
2+
3+
import (
4+
"encoding/json"
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/contracts"
14+
)
15+
16+
// capturingLogsController records the serverID the logs handler forwards, so the
17+
// test can assert it was percent-decoded before lookup.
18+
type capturingLogsController struct {
19+
*MockServerController
20+
gotServerID string
21+
}
22+
23+
func (c *capturingLogsController) GetServerLogs(serverID string, _ int) ([]contracts.LogEntry, error) {
24+
c.gotServerID = serverID
25+
return []contracts.LogEntry{}, nil
26+
}
27+
28+
// TestGetServerLogs_SlashServerIDUnescaped reproduces the daemon-backed half of
29+
// MCP-1111 / #598: official-registry server ids are namespace/name and chi routes
30+
// on RawPath, so the {id} param arrives percent-encoded
31+
// (io.github.evidai%2Fpolymarket-guard). handleGetServerLogs must url.PathUnescape
32+
// it before lookup, otherwise `mcpproxy upstream logs io.github.evidai/polymarket-guard`
33+
// targets the literal encoded name and never finds the server.
34+
func TestGetServerLogs_SlashServerIDUnescaped(t *testing.T) {
35+
logger := zaptest.NewLogger(t).Sugar()
36+
controller := &capturingLogsController{MockServerController: &MockServerController{}}
37+
server := NewServer(controller, logger, nil)
38+
39+
req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/io.github.evidai%2Fpolymarket-guard/logs?tail=10", http.NoBody)
40+
w := httptest.NewRecorder()
41+
server.ServeHTTP(w, req)
42+
43+
require.Equal(t, http.StatusOK, w.Code, "logs request should succeed; body=%s", w.Body.String())
44+
var resp contracts.APIResponse
45+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
46+
assert.Equal(t, "io.github.evidai/polymarket-guard", controller.gotServerID,
47+
"server id must be percent-decoded before lookup")
48+
}

internal/logs/logger.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
77
"io"
88
"os"
9+
"strings"
910
"time"
1011

1112
"go.uber.org/zap"
@@ -268,6 +269,44 @@ func CleanupTestWriter(file *os.File) error {
268269
return nil
269270
}
270271

272+
// serverLogFilename builds the per-server log filename from a server name,
273+
// sanitizing characters that would otherwise be interpreted as path separators.
274+
// Official modelcontextprotocol/registry server names are namespace/name (e.g.
275+
// "io.github.evidai/polymarket-guard"); without sanitizing the "/" the filename
276+
// would resolve to a nested directory instead of a single flat log file
277+
// (MCP-1111). All derivation sites (file writers + tail reader) must call this
278+
// so writes and reads agree on the same path.
279+
func serverLogFilename(serverName string) string {
280+
return fmt.Sprintf("server-%s.log", sanitizeServerLogName(serverName))
281+
}
282+
283+
// ServerLogFilename is the exported accessor for the per-server log filename.
284+
// Read sites outside this package (the REST/daemon log reader in internal/server
285+
// and the no-daemon CLI file reader in cmd/mcpproxy) MUST derive the path through
286+
// this helper so reads resolve to the same sanitized flat file the writers create
287+
// for namespace/name registry servers (MCP-1111). Do not re-derive "server-%s.log"
288+
// by hand at a call site.
289+
func ServerLogFilename(serverName string) string {
290+
return serverLogFilename(serverName)
291+
}
292+
293+
// sanitizeServerLogName replaces every character that is not safe in a single
294+
// filename element with "_", keeping ASCII letters, digits, ".", "-" and "_".
295+
// The result is always a single path element (no "/" or "\\"), so it can never
296+
// escape the log directory or create nested directories.
297+
func sanitizeServerLogName(serverName string) string {
298+
return strings.Map(func(r rune) rune {
299+
switch {
300+
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
301+
return r
302+
case r == '.', r == '-', r == '_':
303+
return r
304+
default:
305+
return '_'
306+
}
307+
}, serverName)
308+
}
309+
271310
// CreateUpstreamServerLogger creates a logger for a specific upstream server
272311
func CreateUpstreamServerLogger(config *config.LogConfig, serverName string) (*zap.Logger, error) {
273312
if config == nil {
@@ -276,7 +315,7 @@ func CreateUpstreamServerLogger(config *config.LogConfig, serverName string) (*z
276315

277316
// Create a copy of the config for the upstream server
278317
serverConfig := *config
279-
serverConfig.Filename = fmt.Sprintf("server-%s.log", serverName)
318+
serverConfig.Filename = serverLogFilename(serverName)
280319
serverConfig.EnableConsole = false // Upstream servers only log to file
281320

282321
// Parse log level
@@ -320,7 +359,7 @@ func CreateCLIUpstreamServerLogger(config *config.LogConfig, serverName string)
320359

321360
// Create a copy of the config for CLI debugging
322361
serverConfig := *config
323-
serverConfig.Filename = fmt.Sprintf("server-%s.log", serverName)
362+
serverConfig.Filename = serverLogFilename(serverName)
324363
serverConfig.EnableConsole = true // CLI debugging: enable console output
325364
serverConfig.EnableFile = false // CLI debugging: disable file output for simplicity
326365

@@ -435,7 +474,7 @@ func ReadUpstreamServerLogTail(config *config.LogConfig, serverName string, line
435474
}
436475

437476
// Get log file path
438-
filename := fmt.Sprintf("server-%s.log", serverName)
477+
filename := serverLogFilename(serverName)
439478
logFilePath, err := GetLogFilePathWithDir(config.LogDir, filename)
440479
if err != nil {
441480
return nil, fmt.Errorf("failed to get log file path for server %s: %w", serverName, err)

internal/logs/logger_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package logs
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// Official modelcontextprotocol/registry server names are namespace/name
13+
// (e.g. "io.github.evidai/polymarket-guard"). The per-server log filename is
14+
// derived from the server name, so an unsanitized "/" would turn
15+
// "server-io.github.evidai/polymarket-guard.log" into a nested directory
16+
// (server-io.github.evidai/) instead of a single flat log file (MCP-1111).
17+
func TestServerLogFilename_SanitizesPathSeparators(t *testing.T) {
18+
cases := []struct {
19+
name string
20+
server string
21+
expected string
22+
}{
23+
{"plain", "github", "server-github.log"},
24+
{"namespaced slash", "io.github.evidai/polymarket-guard", "server-io.github.evidai_polymarket-guard.log"},
25+
{"windows backslash", "ns\\name", "server-ns_name.log"},
26+
{"colon", "host:1234", "server-host_1234.log"},
27+
{"spaces and slashes", "a b/c", "server-a_b_c.log"},
28+
}
29+
for _, tc := range cases {
30+
t.Run(tc.name, func(t *testing.T) {
31+
got := serverLogFilename(tc.server)
32+
assert.Equal(t, tc.expected, got)
33+
// A sanitized filename must never contain an OS path separator.
34+
assert.NotContains(t, got, "/")
35+
assert.NotContains(t, got, "\\")
36+
assert.Equal(t, filepath.Base(got), got, "sanitized filename must be a single path element")
37+
})
38+
}
39+
}
40+
41+
// Regression: creating a logger for a namespaced (slash-bearing) server name
42+
// must produce a single flat log file, not a nested directory, and the tail
43+
// reader must round-trip the same raw name back to that file.
44+
func TestCreateUpstreamServerLogger_NamespacedNameFlatFile(t *testing.T) {
45+
// Use os.MkdirTemp (not t.TempDir) with a best-effort cleanup: the lumberjack
46+
// writer keeps the log file handle open for the lifetime of the logger, and
47+
// Windows cannot remove an open file. t.TempDir's cleanup asserts RemoveAll
48+
// succeeds and would fail the test on Windows; a non-asserting cleanup mirrors
49+
// the existing TestE2E_LogRotation pattern.
50+
logDir, err := os.MkdirTemp("", "mcpproxy-logtest-*")
51+
require.NoError(t, err)
52+
t.Cleanup(func() { _ = os.RemoveAll(logDir) })
53+
const serverName = "io.github.evidai/polymarket-guard"
54+
55+
cfg := DefaultLogConfig()
56+
cfg.LogDir = logDir
57+
cfg.EnableFile = true
58+
cfg.EnableConsole = false
59+
60+
logger, err := CreateUpstreamServerLogger(cfg, serverName)
61+
require.NoError(t, err)
62+
logger.Info("hello from polymarket-guard")
63+
_ = logger.Sync()
64+
65+
// The flat file exists directly in logDir.
66+
flatPath := filepath.Join(logDir, "server-io.github.evidai_polymarket-guard.log")
67+
_, err = os.Stat(flatPath)
68+
require.NoError(t, err, "expected a single flat log file at %s", flatPath)
69+
70+
// No nested directory was created from the "/" in the server name.
71+
nestedDir := filepath.Join(logDir, "server-io.github.evidai")
72+
_, err = os.Stat(nestedDir)
73+
assert.True(t, os.IsNotExist(err), "no nested directory should be created from a namespaced server name")
74+
75+
// The tail reader resolves the same raw name back to the flat file.
76+
lines, err := ReadUpstreamServerLogTail(cfg, serverName, 10)
77+
require.NoError(t, err)
78+
require.NotEmpty(t, lines, "tail reader must round-trip the namespaced server name to its flat log file")
79+
assert.Contains(t, lines[len(lines)-1], "hello from polymarket-guard")
80+
81+
// The exported helper (used by the out-of-package read sites in internal/server
82+
// and cmd/mcpproxy) MUST resolve a namespaced name to the same flat file the
83+
// writer created — otherwise daemon/CLI log reads 404 for slash-bearing names
84+
// (the read-site divergence Codex flagged on PR #604).
85+
assert.Equal(t, flatPath, filepath.Join(logDir, ServerLogFilename(serverName)),
86+
"ServerLogFilename must resolve to the writer's flat file for a namespaced name")
87+
}

internal/server/server.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2282,7 +2282,14 @@ func (s *Server) GetServerLogs(serverName string, tail int) ([]contracts.LogEntr
22822282
}
22832283
}
22842284

2285-
logFile := filepath.Join(logDir, fmt.Sprintf("server-%s.log", serverName))
2285+
logFile := filepath.Join(logDir, logs.ServerLogFilename(serverName))
2286+
2287+
// Defense-in-depth: logs.ServerLogFilename already sanitizes the (user-controlled)
2288+
// server name to a single path element, but verify the resolved path stays inside
2289+
// logDir so a crafted name can never escape the log directory (path-injection barrier).
2290+
if !strings.HasPrefix(filepath.Clean(logFile), filepath.Clean(logDir)+string(os.PathSeparator)) {
2291+
return nil, fmt.Errorf("invalid server name: %s", serverName)
2292+
}
22862293

22872294
// Check if file exists
22882295
if _, err := os.Stat(logFile); os.IsNotExist(err) {

0 commit comments

Comments
 (0)