From ca693562a37c8ddefa8d3f55da6d8d1a087acd76 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 9 Oct 2026 12:57:13 +0200 Subject: [PATCH] fix(policies): restrict http.send to remote endpoints in restrictive mode In restrictive mode, the Rego engine now rejects http.send requests that use TLS options that load certificates or keys from local files or environment variables, and requests whose URL scheme is not http or https. Inline TLS material is still accepted. Permissive mode is unchanged. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 7f271228-5972-4af9-a75f-a912318a4bf5 --- pkg/policies/engine/rego/httpsend.go | 89 ++++++++++++ pkg/policies/engine/rego/httpsend_test.go | 157 ++++++++++++++++++++++ pkg/policies/engine/rego/rego.go | 11 ++ 3 files changed, 257 insertions(+) create mode 100644 pkg/policies/engine/rego/httpsend.go create mode 100644 pkg/policies/engine/rego/httpsend_test.go diff --git a/pkg/policies/engine/rego/httpsend.go b/pkg/policies/engine/rego/httpsend.go new file mode 100644 index 000000000..3e4086e79 --- /dev/null +++ b/pkg/policies/engine/rego/httpsend.go @@ -0,0 +1,89 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rego + +import ( + "context" + "fmt" + "net/url" + "slices" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" +) + +// allowedHTTPSendSchemes are the URL schemes http.send may use. Other schemes, +// such as unix, make the evaluating process connect to local resources. +var allowedHTTPSendSchemes = []string{"http", "https"} + +var httpSendURLKey = ast.StringTerm("url") + +// restrictedHTTPSendOptions are http.send request options that make the +// evaluating process read local files or environment variables. +var restrictedHTTPSendOptions = []*ast.Term{ + ast.StringTerm("tls_ca_cert_file"), + ast.StringTerm("tls_ca_cert_env_variable"), + ast.StringTerm("tls_client_cert_file"), + ast.StringTerm("tls_client_cert_env_variable"), + ast.StringTerm("tls_client_key_file"), + ast.StringTerm("tls_client_key_env_variable"), +} + +type permissiveModeCtxKey struct{} + +// withPermissiveMode marks the evaluation context so that http.send accepts +// the restricted options. Any evaluation without this mark rejects them. +func withPermissiveMode(ctx context.Context) context.Context { + return context.WithValue(ctx, permissiveModeCtxKey{}, true) +} + +func isPermissiveMode(ctx context.Context) bool { + permissive, _ := ctx.Value(permissiveModeCtxKey{}).(bool) + return permissive +} + +// OPA resolves built-ins from a global registry, so http.send can only be +// restricted by replacing its implementation there. +func init() { + httpSend := topdown.GetBuiltin(ast.HTTPSend.Name) + topdown.RegisterBuiltinFunc(ast.HTTPSend.Name, func(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + if !isPermissiveMode(bctx.Context) { + if err := validateHTTPSendRequest(operands); err != nil { + return err + } + } + return httpSend(bctx, operands, iter) + }) +} + +func validateHTTPSendRequest(operands []*ast.Term) error { + req, ok := operands[0].Value.(ast.Object) + if !ok { + return nil + } + if urlTerm := req.Get(httpSendURLKey); urlTerm != nil { + rawURL, _ := urlTerm.Value.(ast.String) + if u, err := url.Parse(string(rawURL)); err == nil && !slices.Contains(allowedHTTPSendSchemes, u.Scheme) { + return fmt.Errorf("url scheme %q is not allowed", u.Scheme) + } + } + for _, opt := range restrictedHTTPSendOptions { + if req.Get(opt) != nil { + return fmt.Errorf("request option %v is not allowed", opt) + } + } + return nil +} diff --git a/pkg/policies/engine/rego/httpsend_test.go b/pkg/policies/engine/rego/httpsend_test.go new file mode 100644 index 000000000..ba4b46699 --- /dev/null +++ b/pkg/policies/engine/rego/httpsend_test.go @@ -0,0 +1,157 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rego + +import ( + "context" + "fmt" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/policies/engine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const httpSendPolicyTemplate = `package main + +import rego.v1 + +result := {"skipped": false, "violations": violations} + +violations contains "request sent" if { + http.send(%[1]s) +} + +matches_parameters if { + http.send(%[1]s) +} +` + +func TestRego_HTTPSendRestrictedOptions(t *testing.T) { + const allowedURL = `"url": "https://www.chainloop.dev"` + + testCases := []struct { + name string + request string + wantErr string + }{ + { + name: "tls_ca_cert_file", + request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_file": "/etc/ssl/ca.pem"}`, + wantErr: `http.send: request option "tls_ca_cert_file" is not allowed`, + }, + { + name: "tls_ca_cert_env_variable", + request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_env_variable": "CA_CERT"}`, + wantErr: `http.send: request option "tls_ca_cert_env_variable" is not allowed`, + }, + { + name: "tls_client_cert_file", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_cert_file": "/etc/ssl/cert.pem"}`, + wantErr: `http.send: request option "tls_client_cert_file" is not allowed`, + }, + { + name: "tls_client_cert_env_variable", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_cert_env_variable": "CLIENT_CERT"}`, + wantErr: `http.send: request option "tls_client_cert_env_variable" is not allowed`, + }, + { + name: "tls_client_key_file", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_key_file": "/etc/ssl/key.pem"}`, + wantErr: `http.send: request option "tls_client_key_file" is not allowed`, + }, + { + name: "tls_client_key_env_variable", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_key_env_variable": "CLIENT_KEY"}`, + wantErr: `http.send: request option "tls_client_key_env_variable" is not allowed`, + }, + { + name: "empty value is rejected", + request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_file": ""}`, + wantErr: `http.send: request option "tls_ca_cert_file" is not allowed`, + }, + { + name: "option key built at runtime", + request: `object.union({"method": "GET", ` + allowedURL + `}, {concat("_", ["tls", "client", "key", "file"]): "/etc/ssl/key.pem"})`, + wantErr: `http.send: request option "tls_client_key_file" is not allowed`, + }, + { + name: "unix scheme with an allowed host", + request: `{"method": "GET", "url": "unix://www.chainloop.dev/info?socket=%2Fvar%2Frun%2Fapp.sock"}`, + wantErr: `http.send: url scheme "unix" is not allowed`, + }, + { + name: "url without scheme", + request: `{"method": "GET", "url": "www.chainloop.dev"}`, + wantErr: `http.send: url scheme "" is not allowed`, + }, + { + name: "upper case https scheme reaches http.send", + request: `{"method": "GET", "url": "HTTPS://example.com"}`, + wantErr: "http.send: disallowed host: example.com", + }, + { + name: "request without restricted options reaches http.send", + request: `{"method": "GET", "url": "https://example.com"}`, + wantErr: "http.send: disallowed host: example.com", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := NewEngine() + policy := &engine.Policy{ + Name: "http-send", + Source: fmt.Appendf(nil, httpSendPolicyTemplate, tc.request), + } + + _, verifyErr := r.Verify(context.TODO(), policy, []byte(`{}`), nil) + _, matchErr := r.MatchesParameters(context.TODO(), policy, nil, nil) + + for _, err := range []error{verifyErr, matchErr} { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } + }) + } +} + +func TestRego_HTTPSendPermissiveMode(t *testing.T) { + // raise_error=false makes http.send return its own error in the response, + // which shows that the request got past the restriction. + const policySource = `package main + +import rego.v1 + +result := {"skipped": false, "violations": violations} + +violations contains resp.error.message if { + resp := http.send({ + "method": "GET", + "url": "unix://www.chainloop.dev/info?socket=%2Fnonexistent%2Fapp.sock", + "tls_ca_cert_file": "/nonexistent/ca.pem", + "raise_error": false, + }) +} +` + + r := NewEngine(engine.WithOperatingMode(int32(EnvironmentModePermissive))) + result, err := r.Verify(context.TODO(), &engine.Policy{Name: "http-send", Source: []byte(policySource)}, []byte(`{}`), nil) + require.NoError(t, err) + require.Len(t, result.Violations, 1) + assert.Contains(t, result.Violations[0].Violation, "/nonexistent/ca.pem") + assert.NotContains(t, result.Violations[0].Violation, "is not allowed") +} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index f923e4fba..9b718defe 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -144,6 +144,7 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte // wins, since context.WithTimeout never extends the parent's. ctx, cancel := context.WithTimeout(ctx, r.executionTimeout) defer cancel() + ctx = r.evalContext(ctx) policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) @@ -452,11 +453,21 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v return matchesEvaluation, nil } +// evalContext marks the context of permissive engines, which lifts the +// http.send request option restrictions. +func (r *Engine) evalContext(ctx context.Context) context.Context { + if r.operatingMode == EnvironmentModePermissive { + return withPermissiveMode(ctx) + } + return ctx +} + // Evaluates a single rule and returns its boolean result func (r *Engine) evaluateMatchingRule(ctx context.Context, ruleName string, parsedModule *ast.Module, decodedInput interface{}) (result bool, found bool, err error) { // Bound the evaluation, same as Verify does for the main rule ctx, cancel := context.WithTimeout(ctx, r.executionTimeout) defer cancel() + ctx = r.evalContext(ctx) // Add input regoInput := rego.Input(decodedInput)