Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 55 additions & 19 deletions internal/discovery/websocket_detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package discovery
import (
"context"
"net"
"strconv"

"github.com/hackwither/reap/internal/probe/common"
)
Expand All @@ -27,29 +28,64 @@ func (d *mcpWebSocketDetector) Kinds() []CandidateKind {
}

func (d *mcpWebSocketDetector) Detect(ctx context.Context, c Candidate, opts DetectOptions) (*Fingerprint, error) {
if c.URL == "" {
return nil, nil
}
dialer := &net.Dialer{Timeout: opts.Timeout}
var headers map[string]string
if opts.AuthHeader != "" {
headers = map[string]string{"Authorization": opts.AuthHeader}
}
conn, _, err := common.DialWebSocket(dialer, c.URL, headers)
if err != nil {
return nil, nil // unreachable candidate, or not a conformant WebSocket server — not a Detector failure
for _, targetURL := range websocketCandidateURLs(c) {
conn, _, err := common.DialWebSocket(dialer, targetURL, headers)
if err != nil {
continue // unreachable candidate, or not a conformant WebSocket server
}
conn.Close()

matchedCandidate := c
matchedCandidate.URL = targetURL
return &Fingerprint{
Candidate: matchedCandidate,
Protocol: "mcp",
Transport: "websocket",
Confidence: "medium", // capped: non-standard transport, upgrade-only confirmation, no JSON-RPC round trip yet
Evidence: map[string]any{
"upgrade_confirmed": true,
"note": "WebSocket is not part of the official MCP spec; this only confirms a conformant upgrade handshake, not an MCP JSON-RPC round trip",
},
DetectorID: d.ID(),
}, nil
}
return nil, nil
}

func websocketCandidateURLs(c Candidate) []string {
if c.Kind == KindURL {
if c.URL == "" {
return nil
}
return []string{c.URL}
}
if c.Kind != KindHostPort {
return nil
}
host, port := c.Host, c.Port
if host == "" && c.RawInput != "" {
if parsedHost, parsedPort, err := net.SplitHostPort(c.RawInput); err == nil {
host = parsedHost
if port == 0 {
port, _ = strconv.Atoi(parsedPort)
}
} else {
host = c.RawInput
}
}
if port != 0 {
host = net.JoinHostPort(host, strconv.Itoa(port))
}
var urls []string
for _, scheme := range []string{"http", "https"} {
for _, path := range MCPWellKnownPaths {
urls = append(urls, scheme+"://"+host+path)
}
}
defer conn.Close()

return &Fingerprint{
Candidate: c,
Protocol: "mcp",
Transport: "websocket",
Confidence: "medium", // capped: non-standard transport, upgrade-only confirmation, no JSON-RPC round trip yet
Evidence: map[string]any{
"upgrade_confirmed": true,
"note": "WebSocket is not part of the official MCP spec; this only confirms a conformant upgrade handshake, not an MCP JSON-RPC round trip",
},
DetectorID: d.ID(),
}, nil
return urls
}
20 changes: 20 additions & 0 deletions internal/discovery/websocket_detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ func TestWebSocketDetector_MatchesConformantUpgrade(t *testing.T) {
}
}

func TestWebSocketDetector_MatchesHostPortCandidate(t *testing.T) {
srv := wsUpgradeServer()
defer srv.Close()

det := &mcpWebSocketDetector{}
fp, err := det.Detect(context.Background(), Candidate{
Kind: KindHostPort,
RawInput: srv.URL[len("http://"):],
}, DetectOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fp == nil {
t.Fatal("expected a fingerprint match against a host:port WS candidate, got nil")
}
if fp.Candidate.URL == "" {
t.Fatal("expected fingerprint to record the URL that upgraded")
}
}

// TestWebSocketDetector_NoFalsePositiveOnPlainHTTP is the false-positive
// discipline check: a server that never upgrades at all must not match.
func TestWebSocketDetector_NoFalsePositiveOnPlainHTTP(t *testing.T) {
Expand Down
47 changes: 41 additions & 6 deletions internal/probe/mcp/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ func reproBody(method string, params any) string {
var httpOnlyTransports = []string{"http-streamable", "http-sse-legacy"}
var anyTransport = []string{"*"}

type anonymousSessionProvider interface {
AnonymousSession() (probe.Session, error)
}

func anonymousSession(s probe.Session) (probe.Session, error) {
provider, ok := s.(anonymousSessionProvider)
if !ok {
return nil, fmt.Errorf("session does not support a separate anonymous connection")
}
return provider.AnonymousSession()
}

// closeSession releases persistent transport state when a probe created a
// separate anonymous session. Streamable HTTP sessions need no explicit
// cleanup, while WebSocket and legacy-SSE sessions keep connections open.
func closeSession(s probe.Session) {
if closer, ok := s.(io.Closer); ok {
_ = closer.Close()
}
}

// streamableHTTPOnly is for probes that depend on a mechanism specific to
// the streamable-HTTP session implementation (e.g. the Mcp-Session-Id
// response header it captures) that has no equivalent in legacy-SSE or
Expand Down Expand Up @@ -364,7 +385,12 @@ func (p *oauthMetadataPostureProbe) Transports() []string { return httpOnlyTrans
// signal that must not be reported as "published but incomplete."
func (p *oauthMetadataPostureProbe) Run(ctx context.Context, s probe.Session, r *report.Report) error {
// --- Bearer challenge: observed on the resource's own 401, not metadata. ---
unauthRaw, unauthErr := s.Do(ctx, "tools/list", map[string]any{}, probe.WithNoAuth())
unauthSess, err := anonymousSession(s)
if err != nil {
return nil
}
defer closeSession(unauthSess)
unauthRaw, unauthErr := unauthSess.Do(ctx, "tools/list", map[string]any{})
sawChallenge := unauthErr == nil && unauthRaw != nil && unauthRaw.StatusCode == http.StatusUnauthorized
if sawChallenge {
wwwAuth := unauthRaw.Headers.Get("WWW-Authenticate")
Expand Down Expand Up @@ -731,10 +757,14 @@ func (p *unauthToolsListProbe) Protocol() string { return "mcp" }
func (p *unauthToolsListProbe) Transports() []string { return anyTransport }

func (p *unauthToolsListProbe) Run(ctx context.Context, s probe.Session, r *report.Report) error {
// Re-issue tools/list explicitly WITHOUT the auth header, regardless of
// whether the initial handshake used one. This answers the specific
// question: "can an anonymous caller enumerate tools?"
raw, err := s.Do(ctx, "tools/list", map[string]any{}, probe.WithNoAuth())
// Use a fresh connection so no Authorization header, MCP session ID, or
// authenticated persistent transport state can affect this observation.
unauthSess, err := anonymousSession(s)
if err != nil {
return nil
}
defer closeSession(unauthSess)
raw, err := unauthSess.Do(ctx, "tools/list", map[string]any{})
Comment on lines 759 to +767
if err != nil {
return nil // network failure is not a finding; leave silent, CLI logs errors separately
}
Expand Down Expand Up @@ -1085,8 +1115,13 @@ func (p *resourcesPromptsExposureProbe) Protocol() string { return "mcp" }
func (p *resourcesPromptsExposureProbe) Transports() []string { return anyTransport }

func (p *resourcesPromptsExposureProbe) Run(ctx context.Context, s probe.Session, r *report.Report) error {
unauthSess, err := anonymousSession(s)
if err != nil {
return nil
}
defer closeSession(unauthSess)
for _, method := range []string{"resources/list", "prompts/list"} {
raw, err := s.Do(ctx, method, map[string]any{}, probe.WithNoAuth())
raw, err := unauthSess.Do(ctx, method, map[string]any{})
if err != nil || raw.StatusCode != 200 {
continue
}
Expand Down
85 changes: 85 additions & 0 deletions internal/probe/mcp/checks_test.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,102 @@
package mcp

import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/hackwither/reap/internal/probe"
"github.com/hackwither/reap/internal/probe/common"
"github.com/hackwither/reap/internal/report"
)
Comment on lines 3 to 16

func authRequiredWSServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
key := r.Header.Get("Sec-WebSocket-Key")
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "webserver does not support hijacking", http.StatusInternalServerError)
return
}
conn, buf, err := hj.Hijack()
if err != nil {
http.Error(w, "websocket hijack failed", http.StatusInternalServerError)
return
}
defer conn.Close()
buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + common.ExpectedWebSocketAccept(key) + "\r\n\r\n")
_ = buf.Flush()
reader := bufio.NewReader(buf)
for {
fin, opcode, payload, err := readWSFrame(reader)
if err != nil {
return
}
if !fin || opcode != wsOpText {
continue
}
var req struct {
ID int `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(payload, &req) != nil {
continue
}
result := map[string]any{}
switch req.Method {
case "initialize":
result = map[string]any{"protocolVersion": mcpProtocolVersion, "serverInfo": map[string]any{"name": "auth-ws"}, "capabilities": map[string]any{}}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{"name": "secret_tool"}}}
}
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
if writeServerWSFrame(conn, wsOpText, resp) != nil {
return
}
}
}))
}

func TestUnauthToolsListProbeDoesNotReuseAuthenticatedWebSocket(t *testing.T) {
srv := authRequiredWSServer(t)
defer srv.Close()
wsURL := "ws" + srv.URL[len("http"):]
sess, err := NewWSSession(wsURL, "Bearer secret", 5*time.Second)
if err != nil {
t.Fatalf("authenticated websocket handshake failed: %v", err)
}
defer sess.conn.Close()

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
init, _, err := InitializeSession(ctx, sess)
if err != nil || init == nil {
t.Fatalf("authenticated initialize failed: %v", err)
}
raw, err := sess.Do(ctx, "tools/list", map[string]any{})
if err != nil || raw.StatusCode != http.StatusOK {
t.Fatalf("authenticated tools/list failed: status=%d err=%v", raw.StatusCode, err)
}

rep := &report.Report{Target: report.Target{URL: wsURL, Protocol: "mcp"}}
if err := (&unauthToolsListProbe{}).Run(ctx, sess, rep); err != nil {
t.Fatalf("probe failed: %v", err)
}
if len(rep.Findings) != 0 {
t.Fatalf("authenticated websocket response was incorrectly reported as anonymous exposure: %+v", rep.Findings)
}
}

type fakeSession struct {
responses map[string]*probe.RawResult
lastHost string
Expand Down
10 changes: 10 additions & 0 deletions internal/probe/mcp/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type rpcRequest struct {
type Session struct {
url string
httpClient *http.Client
timeout time.Duration
authHeader string // e.g. "Bearer xyz", set via --auth-header; empty if none supplied
sessionID string // captured from Mcp-Session-Id response header, if the server issues one
reqID int
Expand All @@ -44,10 +45,19 @@ func NewSession(url, authHeader string, timeout time.Duration) *Session {
return &Session{
url: url,
authHeader: authHeader,
timeout: timeout,
httpClient: &http.Client{Timeout: timeout},
}
}

// AnonymousSession returns a fresh streamable-HTTP session with no
// authentication or inherited MCP session ID. It is intentionally separate
// from Do(WithNoAuth): an anonymous probe must not reuse authenticated
// transport state.
func (s *Session) AnonymousSession() (probe.Session, error) {
return NewSession(s.url, "", s.timeout), nil
}

func (s *Session) TargetURL() string { return s.url }

func (s *Session) Do(ctx context.Context, method string, params any, opts ...probe.ReqOption) (*probe.RawResult, error) {
Expand Down
Loading