Skip to content

Commit 89dfb99

Browse files
fix(scanner): resolve source for runtime-added Docker-isolated stdio servers (MCP-2123 Defect A) (#643)
* fix(scanner): resolve source for runtime-added Docker-isolated stdio servers Two independent defects broke security scans of Docker-isolated stdio servers added at runtime from the official registry (slash/dot names like com.pulsemcp/google-flights), surfacing as "No Source Available" (source_method=none) with no tools.json and a skipped Pass 2. 1. Stale config snapshot (primary). configServerInfoProvider iterated a config captured at boot, so servers added at runtime were invisible to GetServerInfo. The scanner then ran with no ServerInfo: source stayed "none", tool definitions were never exported, and Pass 2 was skipped with "No server info available". The provider now resolves against the live config snapshot (runtime.Config), falling back to the boot snapshot. 2. Container-name sanitizer mismatch. The scanner's docker-ps lookup mapped '.' to '-' while the launcher preserves it, so the 'mcpproxy-<sanitized>-' filter never matched the real container for any dotted name and docker_extract silently fell through. Both sides now share internal/dockernaming.SanitizeServerName so the rule can't drift. Adds regression tests for both. Defect B (frontend scan-report link not URL-encoding the scan id) is tracked separately. Related #MCP-2123 * fix(scanner): sanitize slash-named servers in temp-dir patterns + decode scan-report jobId Follow-up to the same MCP-2123 branch: once the sanitizer fix makes the container actually match, two more defects surface for official-registry slash-named servers (verified end-to-end by CEO on com.pulsemcp/google-flights): 1. os.MkdirTemp rejects a pattern containing a path separator, so the three extraction temp-dir patterns (mcpproxy-scan-%s-, -scan-full-%s-, -scan-tools-%s-) failed for '/'-containing names and source fell back to 'none'. Sanitize the name via dockernaming.SanitizeServerName first. 2. handleGetScanReportByJobID read the raw chi jobId param. Scan ids are scan-<serverName>-<ts> and contain '/', arriving percent-encoded, so the report endpoint 404'd. Decode via decodePathParam (same pattern the /servers subtree uses). Verified result: source_method=docker_extract, 23 files extracted, tools_exported=3, report endpoint HTTP 200. Adds regression tests: TestScanTempDirPatternHandlesSlashNames, TestDecodePathParamScanJobID. Related #MCP-2123 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(scanner): drop user data from scan temp-dir patterns (go/path-injection) The user-controlled server name in the os.MkdirTemp patterns was purely cosmetic — the random suffix already guarantees a unique dir. Embedding it introduced a go/path-injection CodeQL high alert (taint flows into the path expression even through dockernaming.SanitizeServerName) and was the original cause of the slash-name rejection bug. Make all three scan temp-dir patterns constant, killing the taint at the source and trivially handling slash-named servers. Drop the now-unused dockernaming import from service.go; keep it in source_resolver.go (still used by findServerContainer). Update the MCP-2123 regression test to assert the patterns are constant and carry no user data. Related #643 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 25a1e10 commit 89dfb99

11 files changed

Lines changed: 321 additions & 76 deletions

File tree

internal/dockernaming/naming.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Package dockernaming centralises the rule for turning an MCPProxy server name
2+
// into the sanitized token used inside a Docker container name
3+
// (mcpproxy-<sanitized>-<suffix>).
4+
//
5+
// It exists so the component that NAMES a container at launch
6+
// (internal/upstream/core) and the component that LOOKS UP a running container
7+
// by name (internal/security/scanner) share one source of truth. They used to
8+
// carry independent sanitizers that disagreed on '.': the launcher preserved it
9+
// (Docker allows '.') while the scanner mapped it to '-'. For official-registry
10+
// servers — whose names are a dotted namespace plus a slash, e.g.
11+
// "com.pulsemcp/google-flights" — that mismatch meant the scanner's
12+
// `docker ps --filter name=mcpproxy-<sanitized>-` prefix never matched the real
13+
// container, so source extraction silently fell through to "No Source Available"
14+
// (MCP-2123). Keeping the rule in one leaf package makes that drift impossible.
15+
package dockernaming
16+
17+
import (
18+
"regexp"
19+
"strings"
20+
)
21+
22+
var (
23+
invalidContainerChars = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
24+
leadingAlphanumeric = regexp.MustCompile(`^[a-zA-Z0-9]`)
25+
)
26+
27+
// maxSanitizedLen bounds the sanitized token so the full container name
28+
// (mcpproxy- prefix + token + - + 4-char suffix) stays well under Docker's
29+
// 253-char limit.
30+
const maxSanitizedLen = 200
31+
32+
// SanitizeServerName converts a server name into a valid Docker container-name
33+
// token. Docker container names may contain [a-zA-Z0-9][a-zA-Z0-9_.-]*, so this
34+
// preserves letters, digits, '_', '.', and '-' and replaces any other run of
35+
// characters with a single '-'. It then collapses consecutive hyphens, ensures
36+
// the result starts with an alphanumeric character, trims trailing '-'/'.', and
37+
// truncates to a safe length.
38+
func SanitizeServerName(name string) string {
39+
sanitized := invalidContainerChars.ReplaceAllString(name, "-")
40+
41+
for strings.Contains(sanitized, "--") {
42+
sanitized = strings.ReplaceAll(sanitized, "--", "-")
43+
}
44+
45+
if sanitized != "" && !leadingAlphanumeric.MatchString(sanitized) {
46+
sanitized = "server-" + sanitized
47+
for strings.Contains(sanitized, "--") {
48+
sanitized = strings.ReplaceAll(sanitized, "--", "-")
49+
}
50+
}
51+
52+
sanitized = strings.TrimRight(sanitized, "-.")
53+
54+
if sanitized == "" {
55+
sanitized = "server"
56+
}
57+
58+
if len(sanitized) > maxSanitizedLen {
59+
sanitized = sanitized[:maxSanitizedLen]
60+
sanitized = strings.TrimRight(sanitized, "-.")
61+
}
62+
63+
return sanitized
64+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package dockernaming
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestSanitizeServerName(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
expected string
13+
}{
14+
// Regression cases for MCP-2123: official-registry servers carry a
15+
// dotted namespace AND a slash (namespace/name). The dot MUST be
16+
// preserved (Docker allows it) while the slash becomes a hyphen, so the
17+
// scanner's container-name prefix matches what the launcher actually
18+
// named the container (mcpproxy-com.pulsemcp-google-flights-<suffix>).
19+
{name: "official registry namespaced", input: "com.pulsemcp/google-flights", expected: "com.pulsemcp-google-flights"},
20+
{name: "github official namespaced", input: "io.github.owner/repo", expected: "io.github.owner-repo"},
21+
22+
// Parity with the launcher's existing sanitizer
23+
// (internal/upstream/core sanitizeServerNameForContainer).
24+
{name: "simple name", input: "github-server", expected: "github-server"},
25+
{name: "name with spaces", input: "my server name", expected: "my-server-name"},
26+
{name: "name with special characters", input: "server@#$%^&*()", expected: "server"},
27+
{name: "name starting with invalid character", input: "-invalid-start", expected: "server-invalid-start"},
28+
{name: "name with dots", input: "server.name.test", expected: "server.name.test"},
29+
{name: "name with underscores", input: "server_name_test", expected: "server_name_test"},
30+
{name: "empty name", input: "", expected: "server"},
31+
{name: "name with multiple consecutive special chars", input: "server!!!@@@name", expected: "server-name"},
32+
{name: "name ending with hyphens and dots", input: "server-name-..", expected: "server-name"},
33+
{name: "very long name", input: strings.Repeat("a", 250), expected: strings.Repeat("a", 200)},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
if got := SanitizeServerName(tt.input); got != tt.expected {
39+
t.Errorf("SanitizeServerName(%q) = %q, want %q", tt.input, got, tt.expected)
40+
}
41+
})
42+
}
43+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package httpapi
2+
3+
import "testing"
4+
5+
// TestDecodePathParamScanJobID is the MCP-2123 regression guard for the
6+
// scan-report-by-job-id route. Scan job IDs are "scan-<serverName>-<ts>", and
7+
// official-registry server names contain '/', so the id reaches the handler
8+
// percent-encoded (chi routes on RawPath). handleGetScanReportByJobID must
9+
// percent-decode it before the exact-match job lookup, otherwise "View Full
10+
// Report" 404s for slash-named servers even after the SPA route resolves.
11+
func TestDecodePathParamScanJobID(t *testing.T) {
12+
encoded := "scan-com.pulsemcp%2Fgoogle-flights-1781284446323229000"
13+
want := "scan-com.pulsemcp/google-flights-1781284446323229000"
14+
if got := decodePathParam(encoded); got != want {
15+
t.Errorf("decodePathParam(%q) = %q, want %q", encoded, got, want)
16+
}
17+
18+
// A non-encoded id must pass through unchanged.
19+
plain := "scan-simple-server-123"
20+
if got := decodePathParam(plain); got != plain {
21+
t.Errorf("decodePathParam(%q) = %q, want unchanged", plain, got)
22+
}
23+
}

internal/httpapi/security_scanner.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,12 @@ func (s *Server) handleGetScanReportByJobID(w http.ResponseWriter, r *http.Reque
663663
if !s.requireSecurity(w, r) {
664664
return
665665
}
666-
jobID := chi.URLParam(r, "jobId")
666+
// chi routes on RawPath, so the param arrives percent-encoded. Scan job IDs
667+
// embed the server name (scan-<serverName>-<ts>), and official-registry names
668+
// contain '/', so the slash reaches us as %2F and must be decoded before the
669+
// exact-match job lookup — otherwise the report page 404s for slash-named
670+
// servers even after the SPA route resolves (MCP-2123).
671+
jobID := decodePathParam(chi.URLParam(r, "jobId"))
667672
if jobID == "" {
668673
s.writeError(w, r, http.StatusBadRequest, "job ID is required")
669674
return

internal/security/scanner/service.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,11 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
679679
// If no source dir was resolved (no Docker container, no working_dir),
680680
// create a temp dir so Cisco scanner can at least scan tool definitions.
681681
if req.SourceDir == "" {
682-
tempDir, err := os.MkdirTemp("", fmt.Sprintf("mcpproxy-scan-tools-%s-", serverName))
682+
// The temp-dir name is purely cosmetic — os.MkdirTemp's random suffix
683+
// already guarantees uniqueness. Keep the pattern a constant so no
684+
// user-controlled server name flows into the path (go/path-injection,
685+
// MCP-2155) and slash-named servers are trivially safe (MCP-2123).
686+
tempDir, err := os.MkdirTemp("", "mcpproxy-scan-tools-")
683687
if err == nil {
684688
req.SourceDir = tempDir
685689
// For HTTP/URL servers, preserve the "url" source method and path

internal/security/scanner/source_resolver.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path/filepath"
1010
"strings"
1111

12+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/dockernaming"
1213
"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
1314
"go.uber.org/zap"
1415
)
@@ -307,12 +308,16 @@ func dirLooksLikeSource(dir string) bool {
307308
return found
308309
}
309310

310-
// findServerContainer finds the running Docker container for a server
311-
// MCPProxy names containers as: mcpproxy-<sanitized-server-name>-<suffix>
311+
// findServerContainer finds the running Docker container for a server.
312+
// MCPProxy names containers as: mcpproxy-<sanitized-server-name>-<suffix>.
313+
// The sanitization MUST match the one used to name the container at launch
314+
// (internal/upstream/core), hence the shared dockernaming package — official
315+
// registry names like "com.pulsemcp/google-flights" keep their dots and would
316+
// otherwise never match (MCP-2123).
312317
func (r *SourceResolver) findServerContainer(ctx context.Context, serverName string) (string, error) {
313318
// Use docker ps with filter to find matching containers
314319
cmd := r.dockerCmd(ctx, "ps",
315-
"--filter", fmt.Sprintf("name=mcpproxy-%s-", sanitizeForDocker(serverName)),
320+
"--filter", fmt.Sprintf("name=mcpproxy-%s-", dockernaming.SanitizeServerName(serverName)),
316321
"--format", "{{.ID}}",
317322
"--no-trunc",
318323
)
@@ -346,8 +351,12 @@ func (r *SourceResolver) findServerContainer(ctx context.Context, serverName str
346351
// hoisted into the same shared cache cannot leak into the scan.
347352
func (r *SourceResolver) extractFromContainer(ctx context.Context, containerID string, info ServerInfo) (string, func(), error) {
348353
serverName := info.Name
349-
// Create temp directory for extracted source
350-
tempDir, err := os.MkdirTemp("", fmt.Sprintf("mcpproxy-scan-%s-", serverName))
354+
// Create temp directory for extracted source. Keep the pattern a constant:
355+
// os.MkdirTemp's random suffix already guarantees uniqueness, so embedding the
356+
// (user-controlled) server name added nothing but a go/path-injection taint
357+
// (MCP-2155) and a slash-rejection bug for official-registry names like
358+
// "com.pulsemcp/google-flights" (MCP-2123). Dropping it fixes both.
359+
tempDir, err := os.MkdirTemp("", "mcpproxy-scan-")
351360
if err != nil {
352361
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
353362
}
@@ -806,7 +815,10 @@ func (r *SourceResolver) ResolveFullSource(ctx context.Context, info ServerInfo)
806815
// false positives (e.g. flagging shutil.py or tempfile.py as "malicious").
807816
func (r *SourceResolver) extractFullFromContainer(ctx context.Context, containerID string, info ServerInfo) (string, func(), error) {
808817
serverName := info.Name
809-
tempDir, err := os.MkdirTemp("", fmt.Sprintf("mcpproxy-scan-full-%s-", serverName))
818+
// Keep the pattern a constant — os.MkdirTemp's random suffix guarantees
819+
// uniqueness; embedding the user-controlled server name only added a
820+
// go/path-injection taint (MCP-2155) and a slash-rejection bug (MCP-2123).
821+
tempDir, err := os.MkdirTemp("", "mcpproxy-scan-full-")
810822
if err != nil {
811823
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
812824
}
@@ -1326,8 +1338,3 @@ func (r *SourceResolver) findGitCheckoutByRepo(checkoutsDir, repoName, gitURL st
13261338
}
13271339
return bestPath, nil
13281340
}
1329-
1330-
// sanitizeForDocker removes characters invalid in Docker container names
1331-
func sanitizeForDocker(name string) string {
1332-
return strings.NewReplacer("/", "-", ":", "-", ".", "-", " ", "-").Replace(name)
1333-
}

internal/security/scanner/source_resolver_test.go

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -671,27 +671,11 @@ func TestIsPackageRunnerCommand(t *testing.T) {
671671
}
672672
}
673673

674-
func TestSanitizeForDocker(t *testing.T) {
675-
tests := []struct {
676-
input string
677-
want string
678-
}{
679-
{"simple", "simple"},
680-
{"my-server", "my-server"},
681-
{"org/repo", "org-repo"},
682-
{"host:port", "host-port"},
683-
{"with.dots", "with-dots"},
684-
{"with spaces", "with-spaces"},
685-
}
686-
687-
for _, tt := range tests {
688-
t.Run(tt.input, func(t *testing.T) {
689-
if got := sanitizeForDocker(tt.input); got != tt.want {
690-
t.Errorf("sanitizeForDocker(%q) = %q, want %q", tt.input, got, tt.want)
691-
}
692-
})
693-
}
694-
}
674+
// Container-name sanitization now lives in internal/dockernaming
675+
// (SanitizeServerName) so the scanner's container lookup and the launcher's
676+
// container naming share one rule. Its behavior — including the MCP-2123
677+
// regression case where dotted official-registry names must be preserved — is
678+
// covered by TestSanitizeServerName in that package.
695679

696680
// TestDockerCmdResolvesBinaryViaShellwrap verifies that the SourceResolver
697681
// resolves the docker binary through shellwrap.ResolveDockerPath rather than
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package scanner
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestScanTempDirPatternHandlesSlashNames is the MCP-2123 regression guard for
11+
// the extraction temp dirs. Official-registry server names contain '/'
12+
// (e.g. "com.pulsemcp/google-flights"). os.MkdirTemp rejects a pattern that
13+
// contains a path separator ("pattern contains path separator"), so the
14+
// original raw interpolation of the server name into the pattern
15+
// (extractFromContainer / extractFullFromContainer / StartScan's tool-defs
16+
// fallback) failed and source resolution fell back to "none".
17+
//
18+
// The fix (MCP-2155) drops the user-controlled server name from the pattern
19+
// entirely: the random suffix os.MkdirTemp appends already guarantees a unique
20+
// dir, so the name was purely cosmetic. Removing it kills the slash-rejection
21+
// bug AND the go/path-injection CodeQL alert at the source (no user-provided
22+
// value flows into the path expression). This test proves the three constant
23+
// patterns the scanner now uses are MkdirTemp-safe and carry no user data.
24+
func TestScanTempDirPatternHandlesSlashNames(t *testing.T) {
25+
const slashName = "com.pulsemcp/google-flights"
26+
27+
// The original raw, name-interpolated pattern fails — guards against anyone
28+
// reintroducing the un-sanitized interpolation that broke slash names.
29+
if _, err := os.MkdirTemp("", fmt.Sprintf("mcpproxy-scan-%s-", slashName)); err == nil {
30+
t.Fatalf("expected os.MkdirTemp to reject raw slash-named pattern, but it succeeded")
31+
}
32+
33+
// Every constant pattern the scanner now uses must succeed regardless of the
34+
// server name, and must not embed any user-controlled value.
35+
for _, prefix := range []string{
36+
"mcpproxy-scan-",
37+
"mcpproxy-scan-full-",
38+
"mcpproxy-scan-tools-",
39+
} {
40+
if strings.Contains(prefix, "%") {
41+
t.Fatalf("pattern %q still interpolates a value; user data must not flow into the path", prefix)
42+
}
43+
dir, err := os.MkdirTemp("", prefix)
44+
if err != nil {
45+
t.Fatalf("os.MkdirTemp with constant pattern %q failed: %v", prefix, err)
46+
}
47+
_ = os.RemoveAll(dir)
48+
}
49+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package server
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
10+
)
11+
12+
// TestConfigServerInfoProvider_UsesLiveConfig is the MCP-2123 regression guard.
13+
//
14+
// configServerInfoProvider used to iterate a config snapshot captured at server
15+
// boot. Servers added at runtime (e.g. from the official registry, whose names
16+
// look like "com.pulsemcp/google-flights") were absent from that snapshot, so
17+
// GetServerInfo returned "not found". The scanner then ran with no ServerInfo:
18+
// source resolution stayed "none", no tools.json was exported, and Pass 2 was
19+
// skipped with "No server info available" — exactly the reported defect.
20+
//
21+
// The provider must resolve server info against the LIVE config.
22+
func TestConfigServerInfoProvider_UsesLiveConfig(t *testing.T) {
23+
bootSnapshot := &config.Config{
24+
Servers: []*config.ServerConfig{
25+
{Name: "boot-server", Protocol: "stdio"},
26+
},
27+
}
28+
29+
// Simulates a server added at runtime after boot — present in the live
30+
// config but not in the snapshot the provider was constructed with.
31+
liveConfig := &config.Config{
32+
Servers: []*config.ServerConfig{
33+
{Name: "boot-server", Protocol: "stdio"},
34+
{
35+
Name: "com.pulsemcp/google-flights",
36+
Protocol: "stdio",
37+
Command: "npx",
38+
Args: []string{"-y", "@pulsemcp/google-flights"},
39+
},
40+
},
41+
}
42+
43+
p := &configServerInfoProvider{
44+
cfg: bootSnapshot,
45+
liveConfig: func() *config.Config { return liveConfig },
46+
}
47+
48+
info, err := p.GetServerInfo("com.pulsemcp/google-flights")
49+
require.NoError(t, err, "runtime-added server must be resolvable via live config")
50+
require.NotNil(t, info)
51+
assert.Equal(t, "com.pulsemcp/google-flights", info.Name)
52+
assert.Equal(t, "stdio", info.Protocol)
53+
assert.Equal(t, "npx", info.Command)
54+
}
55+
56+
// TestConfigServerInfoProvider_FallsBackToSnapshot ensures the provider still
57+
// works when no live-config accessor is wired (defensive default).
58+
func TestConfigServerInfoProvider_FallsBackToSnapshot(t *testing.T) {
59+
p := &configServerInfoProvider{
60+
cfg: &config.Config{
61+
Servers: []*config.ServerConfig{{Name: "only-server", Protocol: "stdio"}},
62+
},
63+
}
64+
65+
info, err := p.GetServerInfo("only-server")
66+
require.NoError(t, err)
67+
require.NotNil(t, info)
68+
assert.Equal(t, "only-server", info.Name)
69+
70+
_, err = p.GetServerInfo("missing")
71+
require.Error(t, err)
72+
}

0 commit comments

Comments
 (0)