Skip to content

Commit 5242981

Browse files
authored
fix(policies): restrict http.send to remote endpoints in restrictive mode (#3586)
1 parent 98635a2 commit 5242981

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package rego
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"net/url"
22+
"slices"
23+
24+
"github.com/open-policy-agent/opa/v1/ast"
25+
"github.com/open-policy-agent/opa/v1/topdown"
26+
)
27+
28+
// allowedHTTPSendSchemes are the URL schemes http.send may use. Other schemes,
29+
// such as unix, make the evaluating process connect to local resources.
30+
var allowedHTTPSendSchemes = []string{"http", "https"}
31+
32+
var httpSendURLKey = ast.StringTerm("url")
33+
34+
// restrictedHTTPSendOptions are http.send request options that make the
35+
// evaluating process read local files or environment variables.
36+
var restrictedHTTPSendOptions = []*ast.Term{
37+
ast.StringTerm("tls_ca_cert_file"),
38+
ast.StringTerm("tls_ca_cert_env_variable"),
39+
ast.StringTerm("tls_client_cert_file"),
40+
ast.StringTerm("tls_client_cert_env_variable"),
41+
ast.StringTerm("tls_client_key_file"),
42+
ast.StringTerm("tls_client_key_env_variable"),
43+
}
44+
45+
type permissiveModeCtxKey struct{}
46+
47+
// withPermissiveMode marks the evaluation context so that http.send accepts
48+
// the restricted options. Any evaluation without this mark rejects them.
49+
func withPermissiveMode(ctx context.Context) context.Context {
50+
return context.WithValue(ctx, permissiveModeCtxKey{}, true)
51+
}
52+
53+
func isPermissiveMode(ctx context.Context) bool {
54+
permissive, _ := ctx.Value(permissiveModeCtxKey{}).(bool)
55+
return permissive
56+
}
57+
58+
// OPA resolves built-ins from a global registry, so http.send can only be
59+
// restricted by replacing its implementation there.
60+
func init() {
61+
httpSend := topdown.GetBuiltin(ast.HTTPSend.Name)
62+
topdown.RegisterBuiltinFunc(ast.HTTPSend.Name, func(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error {
63+
if !isPermissiveMode(bctx.Context) {
64+
if err := validateHTTPSendRequest(operands); err != nil {
65+
return err
66+
}
67+
}
68+
return httpSend(bctx, operands, iter)
69+
})
70+
}
71+
72+
func validateHTTPSendRequest(operands []*ast.Term) error {
73+
req, ok := operands[0].Value.(ast.Object)
74+
if !ok {
75+
return nil
76+
}
77+
if urlTerm := req.Get(httpSendURLKey); urlTerm != nil {
78+
rawURL, _ := urlTerm.Value.(ast.String)
79+
if u, err := url.Parse(string(rawURL)); err == nil && !slices.Contains(allowedHTTPSendSchemes, u.Scheme) {
80+
return fmt.Errorf("url scheme %q is not allowed", u.Scheme)
81+
}
82+
}
83+
for _, opt := range restrictedHTTPSendOptions {
84+
if req.Get(opt) != nil {
85+
return fmt.Errorf("request option %v is not allowed", opt)
86+
}
87+
}
88+
return nil
89+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package rego
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"testing"
22+
23+
"github.com/chainloop-dev/chainloop/pkg/policies/engine"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
const httpSendPolicyTemplate = `package main
29+
30+
import rego.v1
31+
32+
result := {"skipped": false, "violations": violations}
33+
34+
violations contains "request sent" if {
35+
http.send(%[1]s)
36+
}
37+
38+
matches_parameters if {
39+
http.send(%[1]s)
40+
}
41+
`
42+
43+
func TestRego_HTTPSendRestrictedOptions(t *testing.T) {
44+
const allowedURL = `"url": "https://www.chainloop.dev"`
45+
46+
testCases := []struct {
47+
name string
48+
request string
49+
wantErr string
50+
}{
51+
{
52+
name: "tls_ca_cert_file",
53+
request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_file": "/etc/ssl/ca.pem"}`,
54+
wantErr: `http.send: request option "tls_ca_cert_file" is not allowed`,
55+
},
56+
{
57+
name: "tls_ca_cert_env_variable",
58+
request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_env_variable": "CA_CERT"}`,
59+
wantErr: `http.send: request option "tls_ca_cert_env_variable" is not allowed`,
60+
},
61+
{
62+
name: "tls_client_cert_file",
63+
request: `{"method": "GET", ` + allowedURL + `, "tls_client_cert_file": "/etc/ssl/cert.pem"}`,
64+
wantErr: `http.send: request option "tls_client_cert_file" is not allowed`,
65+
},
66+
{
67+
name: "tls_client_cert_env_variable",
68+
request: `{"method": "GET", ` + allowedURL + `, "tls_client_cert_env_variable": "CLIENT_CERT"}`,
69+
wantErr: `http.send: request option "tls_client_cert_env_variable" is not allowed`,
70+
},
71+
{
72+
name: "tls_client_key_file",
73+
request: `{"method": "GET", ` + allowedURL + `, "tls_client_key_file": "/etc/ssl/key.pem"}`,
74+
wantErr: `http.send: request option "tls_client_key_file" is not allowed`,
75+
},
76+
{
77+
name: "tls_client_key_env_variable",
78+
request: `{"method": "GET", ` + allowedURL + `, "tls_client_key_env_variable": "CLIENT_KEY"}`,
79+
wantErr: `http.send: request option "tls_client_key_env_variable" is not allowed`,
80+
},
81+
{
82+
name: "empty value is rejected",
83+
request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_file": ""}`,
84+
wantErr: `http.send: request option "tls_ca_cert_file" is not allowed`,
85+
},
86+
{
87+
name: "option key built at runtime",
88+
request: `object.union({"method": "GET", ` + allowedURL + `}, {concat("_", ["tls", "client", "key", "file"]): "/etc/ssl/key.pem"})`,
89+
wantErr: `http.send: request option "tls_client_key_file" is not allowed`,
90+
},
91+
{
92+
name: "unix scheme with an allowed host",
93+
request: `{"method": "GET", "url": "unix://www.chainloop.dev/info?socket=%2Fvar%2Frun%2Fapp.sock"}`,
94+
wantErr: `http.send: url scheme "unix" is not allowed`,
95+
},
96+
{
97+
name: "url without scheme",
98+
request: `{"method": "GET", "url": "www.chainloop.dev"}`,
99+
wantErr: `http.send: url scheme "" is not allowed`,
100+
},
101+
{
102+
name: "upper case https scheme reaches http.send",
103+
request: `{"method": "GET", "url": "HTTPS://example.com"}`,
104+
wantErr: "http.send: disallowed host: example.com",
105+
},
106+
{
107+
name: "request without restricted options reaches http.send",
108+
request: `{"method": "GET", "url": "https://example.com"}`,
109+
wantErr: "http.send: disallowed host: example.com",
110+
},
111+
}
112+
113+
for _, tc := range testCases {
114+
t.Run(tc.name, func(t *testing.T) {
115+
r := NewEngine()
116+
policy := &engine.Policy{
117+
Name: "http-send",
118+
Source: fmt.Appendf(nil, httpSendPolicyTemplate, tc.request),
119+
}
120+
121+
_, verifyErr := r.Verify(context.TODO(), policy, []byte(`{}`), nil)
122+
_, matchErr := r.MatchesParameters(context.TODO(), policy, nil, nil)
123+
124+
for _, err := range []error{verifyErr, matchErr} {
125+
require.Error(t, err)
126+
assert.Contains(t, err.Error(), tc.wantErr)
127+
}
128+
})
129+
}
130+
}
131+
132+
func TestRego_HTTPSendPermissiveMode(t *testing.T) {
133+
// raise_error=false makes http.send return its own error in the response,
134+
// which shows that the request got past the restriction.
135+
const policySource = `package main
136+
137+
import rego.v1
138+
139+
result := {"skipped": false, "violations": violations}
140+
141+
violations contains resp.error.message if {
142+
resp := http.send({
143+
"method": "GET",
144+
"url": "unix://www.chainloop.dev/info?socket=%2Fnonexistent%2Fapp.sock",
145+
"tls_ca_cert_file": "/nonexistent/ca.pem",
146+
"raise_error": false,
147+
})
148+
}
149+
`
150+
151+
r := NewEngine(engine.WithOperatingMode(int32(EnvironmentModePermissive)))
152+
result, err := r.Verify(context.TODO(), &engine.Policy{Name: "http-send", Source: []byte(policySource)}, []byte(`{}`), nil)
153+
require.NoError(t, err)
154+
require.Len(t, result.Violations, 1)
155+
assert.Contains(t, result.Violations[0].Violation, "/nonexistent/ca.pem")
156+
assert.NotContains(t, result.Violations[0].Violation, "is not allowed")
157+
}

‎pkg/policies/engine/rego/rego.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte
144144
// wins, since context.WithTimeout never extends the parent's.
145145
ctx, cancel := context.WithTimeout(ctx, r.executionTimeout)
146146
defer cancel()
147+
ctx = r.evalContext(ctx)
147148

148149
policyString := string(policy.Source)
149150
parsedModule, err := ast.ParseModule(policy.Name, policyString)
@@ -452,11 +453,21 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v
452453
return matchesEvaluation, nil
453454
}
454455

456+
// evalContext marks the context of permissive engines, which lifts the
457+
// http.send request option restrictions.
458+
func (r *Engine) evalContext(ctx context.Context) context.Context {
459+
if r.operatingMode == EnvironmentModePermissive {
460+
return withPermissiveMode(ctx)
461+
}
462+
return ctx
463+
}
464+
455465
// Evaluates a single rule and returns its boolean result
456466
func (r *Engine) evaluateMatchingRule(ctx context.Context, ruleName string, parsedModule *ast.Module, decodedInput interface{}) (result bool, found bool, err error) {
457467
// Bound the evaluation, same as Verify does for the main rule
458468
ctx, cancel := context.WithTimeout(ctx, r.executionTimeout)
459469
defer cancel()
470+
ctx = r.evalContext(ctx)
460471

461472
// Add input
462473
regoInput := rego.Input(decodedInput)

0 commit comments

Comments
 (0)