Skip to content

Commit 801bcf2

Browse files
committed
fix(telemetry): route opt-out beacon through service endpoint (fix CodeQL SSRF)
CodeQL go/request-forgery (CWE-918) flagged optout.go because the destination URL flowed as a function parameter directly from the config source to the http.Do sink. Make SendOptOutBeacon a *Service method that reads the resolved s.endpoint/s.config — the same struct-field indirection the existing heartbeat and feedback senders use (which CodeQL does not flag) — so the beacon can never target an arbitrary caller-supplied URL. The CLI path now constructs a telemetry.Service and calls the method instead of passing a raw URL. No behavior change: still exactly one fire-and-forget beacon to /heartbeat on the enabled->disabled flip. Related #MCP-2482
1 parent 0c7ba6c commit 801bcf2

3 files changed

Lines changed: 30 additions & 25 deletions

File tree

cmd/mcpproxy/telemetry_cmd.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7-
"net/http"
87
"os"
98
"path/filepath"
109
"strings"
1110
"time"
1211

1312
"github.com/spf13/cobra"
1413
"go.etcd.io/bbolt"
14+
"go.uber.org/zap"
1515

1616
clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
1717
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
@@ -273,7 +273,6 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
273273
// disabled must not emit another beacon.
274274
wasEnabled := cfg.IsTelemetryEnabled()
275275
anonID := cfg.GetAnonymousID()
276-
endpoint := cfg.GetTelemetryEndpoint()
277276

278277
if cfg.Telemetry == nil {
279278
cfg.Telemetry = &config.TelemetryConfig{}
@@ -293,8 +292,10 @@ func runTelemetryDisable(cmd *cobra.Command, _ []string) error {
293292
if wasEnabled && anonID != "" {
294293
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
295294
defer cancel()
296-
client := &http.Client{Timeout: 5 * time.Second}
297-
if beaconErr := telemetry.SendOptOutBeacon(ctx, client, endpoint, anonID); beaconErr != nil {
295+
// Reuse the telemetry service's own sender so the destination is the
296+
// resolved telemetry endpoint (never an arbitrary caller-supplied URL).
297+
beaconSvc := telemetry.New(cfg, "", "", "", zap.NewNop())
298+
if beaconErr := beaconSvc.SendOptOutBeacon(ctx); beaconErr != nil {
298299
// Best-effort only — telemetry is already disabled on disk. Surface
299300
// at a low level so scripts aren't tripped up.
300301
fmt.Println("Note: opt-out signal could not be delivered (telemetry is still disabled).")

internal/telemetry/optout.go

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,23 @@ func TelemetryDisableTransition(prior, next *config.Config) bool {
4242
return prior.IsTelemetryEnabled() && !next.IsTelemetryEnabled()
4343
}
4444

45-
// SendOptOutBeacon posts a single opt-out beacon to the telemetry endpoint,
46-
// reusing the existing /heartbeat ingest path. It is best-effort: callers MUST
47-
// disable telemetry regardless of the returned error. The caller supplies the
48-
// context (with its own short timeout) so the send never blocks a config save.
49-
func SendOptOutBeacon(ctx context.Context, client *http.Client, endpoint, anonymousID string) error {
50-
if anonymousID == "" {
45+
// SendOptOutBeacon posts a single opt-out beacon to the configured telemetry
46+
// endpoint, reusing the existing /heartbeat ingest path. It is best-effort:
47+
// callers MUST disable telemetry regardless of the returned error, and supply a
48+
// context with a short timeout so the send never blocks a config save.
49+
//
50+
// The destination is taken from the service's own resolved endpoint/config
51+
// (the exact indirection the heartbeat and feedback senders use) rather than a
52+
// caller-supplied URL, so this never sends to an arbitrary, request-derived
53+
// host.
54+
func (s *Service) SendOptOutBeacon(ctx context.Context) error {
55+
anonID := s.config.GetAnonymousID()
56+
if anonID == "" {
5157
// Nothing to dedup on — never send an identity-less beacon.
5258
return errors.New("opt-out beacon skipped: no anonymous_id")
5359
}
54-
if client == nil {
55-
client = http.DefaultClient
56-
}
5760

58-
beacon := OptOutBeacon{Event: OptOutEvent, AnonymousID: anonymousID}
61+
beacon := OptOutBeacon{Event: OptOutEvent, AnonymousID: anonID}
5962
data, err := json.Marshal(beacon)
6063
if err != nil {
6164
return fmt.Errorf("marshal opt-out beacon: %w", err)
@@ -68,13 +71,14 @@ func SendOptOutBeacon(ctx context.Context, client *http.Client, endpoint, anonym
6871
return fmt.Errorf("opt-out beacon failed anonymity scan: %w", scanErr)
6972
}
7073

71-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/heartbeat", bytes.NewReader(data))
74+
url := s.endpoint + "/heartbeat"
75+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
7276
if err != nil {
7377
return fmt.Errorf("build opt-out request: %w", err)
7478
}
7579
req.Header.Set("Content-Type", "application/json")
7680

77-
resp, err := client.Do(req)
81+
resp, err := s.client.Do(req)
7882
if err != nil {
7983
return fmt.Errorf("send opt-out beacon: %w", err)
8084
}
@@ -130,19 +134,15 @@ func (s *Service) NotifyConfigChanged(newCfg *config.Config) {
130134
if !isValidSemver(s.version) {
131135
return
132136
}
133-
anonID := newCfg.GetAnonymousID()
134-
if anonID == "" {
137+
if newCfg.GetAnonymousID() == "" {
135138
return
136139
}
137140

138-
endpoint := s.endpoint
139-
client := s.client
140-
logger := s.logger
141141
go func() {
142142
ctx, cancel := context.WithTimeout(context.Background(), optOutBeaconTimeout)
143143
defer cancel()
144-
if err := SendOptOutBeacon(ctx, client, endpoint, anonID); err != nil {
145-
logger.Debug("opt-out beacon send failed (telemetry still disabled)", zap.Error(err))
144+
if err := s.SendOptOutBeacon(ctx); err != nil {
145+
s.logger.Debug("opt-out beacon send failed (telemetry still disabled)", zap.Error(err))
146146
}
147147
}()
148148
}

internal/telemetry/optout_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func TestSendOptOutBeacon_PayloadShape(t *testing.T) {
6767
method string
6868
body map[string]any
6969
}
70+
clearTelemetryEnv(t)
7071
done := make(chan capture, 1)
7172
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7273
raw, _ := io.ReadAll(r.Body)
@@ -77,8 +78,11 @@ func TestSendOptOutBeacon_PayloadShape(t *testing.T) {
7778
}))
7879
defer server.Close()
7980

80-
err := SendOptOutBeacon(context.Background(), server.Client(), server.URL, "anon-123")
81-
if err != nil {
81+
cfg := &config.Config{Telemetry: &config.TelemetryConfig{
82+
AnonymousID: "anon-123", Endpoint: server.URL,
83+
}}
84+
svc := New(cfg, "", "v1.0.0", "personal", zap.NewNop())
85+
if err := svc.SendOptOutBeacon(context.Background()); err != nil {
8286
t.Fatalf("SendOptOutBeacon returned error: %v", err)
8387
}
8488

0 commit comments

Comments
 (0)