Skip to content

Commit

Permalink
Limit http.send in rego evaluation to "normal" public IPs. (#5281)
Browse files Browse the repository at this point in the history
* Limit http.send in rego evaluation to "normal" public IPs.

* Fix lint
  • Loading branch information
evankanderson authored Jan 10, 2025
1 parent f2e2365 commit bcebbae
Show file tree
Hide file tree
Showing 3 changed files with 161 additions and 1 deletion.
2 changes: 1 addition & 1 deletion internal/engine/eval/rego/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
76 changes: 76 additions & 0 deletions internal/engine/eval/rego/http.go
Original file line number Diff line number Diff line change
@@ -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
}
}
84 changes: 84 additions & 0 deletions internal/engine/eval/rego/http_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}

0 comments on commit bcebbae

Please sign in to comment.