Skip to content
Merged
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
89 changes: 89 additions & 0 deletions pkg/policies/engine/rego/httpsend.go
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 157 additions & 0 deletions pkg/policies/engine/rego/httpsend_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
11 changes: 11 additions & 0 deletions pkg/policies/engine/rego/rego.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading