diff --git a/internal/engine/eval/rego/eval.go b/internal/engine/eval/rego/eval.go index 0b2dabf0d7..283a62962d 100644 --- a/internal/engine/eval/rego/eval.go +++ b/internal/engine/eval/rego/eval.go @@ -153,7 +153,7 @@ func (e *Evaluator) Eval( } enrichInputWithEntityProps(input, entity) - rs, err := pq.Eval(ctx, rego.EvalInput(input)) + rs, err := pq.Eval(ctx, rego.EvalInput(input), rego.EvalHTTPRoundTripper(LimitedDialer)) if err != nil { return nil, fmt.Errorf("error evaluating profile. Might be wrong input: %w", err) } diff --git a/internal/engine/eval/rego/http.go b/internal/engine/eval/rego/http.go new file mode 100644 index 0000000000..4588606db2 --- /dev/null +++ b/internal/engine/eval/rego/http.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package rego provides the rego rule evaluator +package rego + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +var blockedRequests metric.Int64Counter +var metricsInit sync.Once + +type dialContextFunc = func(ctx context.Context, network, addr string) (net.Conn, error) + +// LimitedDialer is an HTTP Dialer (Rego topdowmn.CustomizeRoundTripper) which +// allows us to limit the destination of dialed requests to block specific +// network ranges (such as RFC1918 space). It operates by attempting to dial +// the requested URL (going through DNS resolution, etc), and then examining +// the remote IP address via conn.RemoteAddr(). +func LimitedDialer(transport *http.Transport) http.RoundTripper { + metricsInit.Do(func() { + meter := otel.Meter("minder") + var err error + blockedRequests, err = meter.Int64Counter( + "rego.http.blocked_requests", + metric.WithDescription("Number of Rego requests to private addresses blocked during evaluation"), + ) + if err != nil { + zerolog.Ctx(context.Background()).Warn().Err(err).Msg("Creating counter for blocked requests failed") + } + }) + if transport == nil { + var ok bool + transport, ok = http.DefaultTransport.(*http.Transport) + if !ok { + transport = &http.Transport{} + } + } + transport.DialContext = publicOnlyDialer(transport.DialContext) + return transport +} + +func publicOnlyDialer(baseDialer dialContextFunc) dialContextFunc { + if baseDialer == nil { + baseDialer = (&net.Dialer{}).DialContext + } + return func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := baseDialer(ctx, network, addr) + if err != nil { + return nil, err + } + remote, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + return nil, fmt.Errorf("Remote address is not a TCP address") + } + if !remote.IP.IsGlobalUnicast() || remote.IP.IsLoopback() || remote.IP.IsPrivate() { + // We do not need to lock because blockedRequests is initialized in a sync.Once + // which is called before this method + if blockedRequests != nil { + blockedRequests.Add(ctx, 1) + } + // Intentionally do not leak address resolution information + return nil, fmt.Errorf("remote address is not public") + } + return conn, err + } +} diff --git a/internal/engine/eval/rego/http_test.go b/internal/engine/eval/rego/http_test.go new file mode 100644 index 0000000000..dd7cfe85b5 --- /dev/null +++ b/internal/engine/eval/rego/http_test.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package rego provides the rego rule evaluator +package rego_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + engerrors "github.com/mindersec/minder/internal/engine/errors" + "github.com/mindersec/minder/internal/engine/eval/rego" + minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" + "github.com/mindersec/minder/pkg/engine/v1/interfaces" +) + +func TestLimitedDialer(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok": 1}`)) + })) + t.Cleanup(ts.Close) + + ruleDef := ` + package minder + import rego.v1 + + default allow := false + resp := http.send({"url": "%s", "method": "GET", "raise_error": false}) + allow if { + not resp.error + } + message := resp.error.message + ` + + tests := []struct { + name string + url string + wantErr string + }{{ + name: "test blocked fetch by name", + url: ts.URL, + wantErr: "remote address is not public", + }, { + name: "google.com not blocked", + url: "http://www.google.com", + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + eval, err := rego.NewRegoEvaluator( + &minderv1.RuleType_Definition_Eval_Rego{ + Type: rego.DenyByDefaultEvaluationType.String(), + Def: fmt.Sprintf(ruleDef, tt.url), + }, + nil, + ) + require.NoError(t, err, "could not create evaluator") + + emptyPol := map[string]any{} + + res, err := eval.Eval(context.Background(), emptyPol, nil, &interfaces.Result{}) + + if tt.wantErr == "" { + require.NoError(t, err, "expected no error") + require.NotNil(t, res, "expected a result") + return + } + + require.Nil(t, res, "expected nil result") + require.ErrorIs(t, err, engerrors.ErrEvaluationFailed) + detailErr := err.(*engerrors.EvaluationError) + require.Contains(t, detailErr.Details(), tt.wantErr) + }) + } +}