Skip to content

Commit 0fc7574

Browse files
Add opt-in SSRF guard for interceptor URLs
Execute() built an outbound request from an operator-supplied interceptor URL (clientConfig.url) with no host validation, so an interceptor could be pointed at cloud metadata endpoints such as 169.254.169.254, loopback, or internal services, and the EventListener would dispatch the request from its own network context. This resolves the interceptor URL host and rejects loopback, link-local, private, and unspecified addresses when the new interceptors.block-private-interceptor-urls feature flag is enabled. The flag is off by default to preserve backwards compatibility, and hosts in the existing github.enterprise-host-allowlist are exempt so legitimate in-cluster interceptor URLs on private ranges stay reachable. The design mirrors the existing enterprise-host-allowlist hardening. Signed-off-by: avneetbansal-aws <[email protected]>
1 parent 2c9aa0c commit 0fc7574

11 files changed

Lines changed: 486 additions & 3 deletions

File tree

‎config/config-feature-flags.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,12 @@ data:
3232
# It is strongly encouraged to be set to "true" for security, and the setting will be removed
3333
# and always enforced in a later version
3434
interceptors.github.use-enterprise-host-allowlist: "false"
35+
# When "true", the EventListener rejects interceptor URLs that resolve to
36+
# loopback, link-local, private, or unspecified addresses before sending the
37+
# request. This guards against SSRF toward cloud metadata endpoints (for
38+
# example 169.254.169.254) and internal services. Defaults to "false" for
39+
# backwards compatibility. Hosts listed in the
40+
# github.enterprise-host-allowlist key of the config-triggers-core-interceptors
41+
# ConfigMap are exempt, so legitimate interceptor URLs on private ranges can
42+
# still be reached.
43+
interceptors.block-private-interceptor-urls: "false"

‎docs/install.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ in the `config/feature-flags-triggers.yaml` file.
9797
This setting will default to `"true"` in a later release and eventually
9898
be removed entirely as always on.
9999

100+
+ Block private interceptor URLs. Set `interceptors.block-private-interceptor-urls:`
101+
to `"true"` to reject interceptor URLs that resolve to loopback, link-local,
102+
private, or unspecified addresses before the EventListener sends the request.
103+
This guards against SSRF toward cloud metadata endpoints such as
104+
`169.254.169.254` and other internal services. It defaults to `"false"` for
105+
backwards compatibility. Hosts listed in the `github.enterprise-host-allowlist`
106+
key of the `config-triggers-core-interceptors` ConfigMap are exempt, so
107+
interceptors served on in-cluster private addresses stay reachable when the
108+
guard is on.
109+
100110
## Further reading
101111

102112
+ [Get started with Tekton Triggers][get-started]

‎pkg/adapter/adapter.go‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,18 @@ import (
3737
triggertemplatesinformer "github.com/tektoncd/triggers/pkg/client/injection/informers/triggers/v1beta1/triggertemplate"
3838

3939
cloudevents "github.com/cloudevents/sdk-go/v2"
40+
"github.com/tektoncd/triggers/pkg/apis/config"
4041
"github.com/tektoncd/triggers/pkg/client/clientset/versioned/scheme"
42+
"github.com/tektoncd/triggers/pkg/interceptors"
4143
"github.com/tektoncd/triggers/pkg/sink"
4244
"go.uber.org/zap"
4345
corev1 "k8s.io/api/core/v1"
46+
apierrors "k8s.io/apimachinery/pkg/api/errors"
47+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
4448
"k8s.io/apimachinery/pkg/labels"
4549
"k8s.io/apimachinery/pkg/util/wait"
4650
"k8s.io/apimachinery/pkg/watch"
51+
kubernetes "k8s.io/client-go/kubernetes"
4752
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
4853
"k8s.io/client-go/tools/record"
4954
"knative.dev/eventing/pkg/adapter/v2"
@@ -205,6 +210,49 @@ func (s *sinker) getCertFromInterceptor(certPool *x509.CertPool) error {
205210
return nil
206211
}
207212

213+
// interceptorURLValidator builds the SSRF guard for interceptor URLs from the
214+
// feature-flags and core-interceptors ConfigMaps. The guard is off unless the
215+
// operator sets interceptors.block-private-interceptor-urls to true, and it
216+
// reuses the github.enterprise-host-allowlist entries as an escape hatch so
217+
// legitimate interceptor hosts on private ranges remain reachable. Missing
218+
// ConfigMaps are treated as "feature off" rather than a startup failure.
219+
func interceptorURLValidator(ctx context.Context, kubeClient kubernetes.Interface, namespace string, logger *zap.SugaredLogger) *interceptors.URLValidator {
220+
loadConfigMap := func(name string) map[string]string {
221+
cm, err := kubeClient.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
222+
if err != nil {
223+
if !apierrors.IsNotFound(err) && logger != nil {
224+
logger.Warnf("could not read ConfigMap %q for interceptor URL validation: %v", name, err)
225+
}
226+
return nil
227+
}
228+
return cm.Data
229+
}
230+
231+
flags, err := config.NewFeatureFlagsFromMap(loadConfigMap(config.GetFeatureFlagsConfigName()))
232+
if err != nil {
233+
if logger != nil {
234+
logger.Warnf("could not parse feature flags for interceptor URL validation: %v", err)
235+
}
236+
return nil
237+
}
238+
if !flags.InterceptorsBlockPrivateInterceptorURLs {
239+
return nil
240+
}
241+
242+
coreInterceptors, err := config.NewCoreInterceptorsFromMap(loadConfigMap(config.GetCoreInterceptorsConfigName()))
243+
if err != nil {
244+
if logger != nil {
245+
logger.Warnf("could not parse core interceptors config for interceptor URL validation: %v", err)
246+
}
247+
coreInterceptors = &config.CoreInterceptorsConfig{}
248+
}
249+
250+
return &interceptors.URLValidator{
251+
BlockPrivate: true,
252+
Allowlist: coreInterceptors.EnterpriseHostAllowlist,
253+
}
254+
}
255+
208256
func (s *sinker) Start(ctx context.Context) error {
209257
clientObj, err := s.getHTTPClient()
210258
if err != nil {
@@ -213,9 +261,12 @@ func (s *sinker) Start(ctx context.Context) error {
213261
// Create EventListener Sink
214262

215263
dynamicClient := dynamicclient.Get(ctx)
264+
kubeClient := kubeclient.Get(ctx)
265+
266+
urlValidator := interceptorURLValidator(ctx, kubeClient, s.Args.ElNamespace, s.Logger)
216267

217268
r := sink.Sink{
218-
KubeClientSet: kubeclient.Get(ctx),
269+
KubeClientSet: kubeClient,
219270
DiscoveryClient: s.Clients.DiscoveryClient,
220271
DynamicClient: dynamicClient,
221272
TriggersClient: s.Clients.TriggersClient,
@@ -240,6 +291,7 @@ func (s *sinker) Start(ctx context.Context) error {
240291
TriggerTemplateLister: triggertemplatesinformer.Get(s.injCtx).Lister(), //nolint:contextcheck
241292
ClusterInterceptorLister: clusterinterceptorsinformer.Get(s.injCtx).Lister(), //nolint:contextcheck
242293
InterceptorLister: interceptorsinformer.Get(s.injCtx).Lister(), //nolint:contextcheck
294+
InterceptorURLValidator: urlValidator,
243295
}
244296

245297
mux := http.NewServeMux()

‎pkg/apis/config/feature_flags.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const (
3535

3636
interceptorsGitHubUseEnterpriseHostAllowlistKey = "interceptors.github.use-enterprise-host-allowlist"
3737
defaultInterceptorsGitHubUseEnterpriseHostAllowlist = false
38+
39+
interceptorsBlockPrivateInterceptorURLsKey = "interceptors.block-private-interceptor-urls"
40+
defaultInterceptorsBlockPrivateInterceptorURLs = false
3841
)
3942

4043
// FeatureFlags holds the features configurations
@@ -52,6 +55,15 @@ type FeatureFlags struct {
5255
// It is strongly encouraged to be enabled for security, and will be removed
5356
// and always enforced in a later version
5457
InterceptorsGitHubUseEnterpriseHostAllowlist bool
58+
// InterceptorsBlockPrivateInterceptorURLs determines whether the sink
59+
// rejects interceptor URLs that resolve to loopback, link-local, private,
60+
// or unspecified addresses before dispatching the request. This guards
61+
// against SSRF toward cloud metadata endpoints (for example
62+
// 169.254.169.254) and internal services. It is off by default to preserve
63+
// backwards compatibility and can be paired with the
64+
// `github.enterprise-host-allowlist` escape hatch for hosts that must be
65+
// reachable on those ranges.
66+
InterceptorsBlockPrivateInterceptorURLs bool
5567
}
5668

5769
// GetFeatureFlagsConfigName returns the name of the configmap containing all
@@ -81,6 +93,12 @@ func NewFeatureFlagsFromMap(cfgMap map[string]string) (*FeatureFlags, error) {
8193
ff.InterceptorsGitHubUseEnterpriseHostAllowlist = defaultInterceptorsGitHubUseEnterpriseHostAllowlist
8294
}
8395

96+
if v, ok := cfgMap[interceptorsBlockPrivateInterceptorURLsKey]; ok {
97+
ff.InterceptorsBlockPrivateInterceptorURLs = strings.EqualFold(v, "true")
98+
} else {
99+
ff.InterceptorsBlockPrivateInterceptorURLs = defaultInterceptorsBlockPrivateInterceptorURLs
100+
}
101+
84102
return &ff, nil
85103
}
86104

‎pkg/apis/config/feature_flags_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func TestNewFeatureFlagsFromConfigMap(t *testing.T) {
4040
EnableAPIFields: "alpha",
4141
LabelsExclusionPattern: "^abc-",
4242
InterceptorsGitHubUseEnterpriseHostAllowlist: true,
43+
InterceptorsBlockPrivateInterceptorURLs: true,
4344
},
4445
fileName: "feature-flags-all-flags-set",
4546
}, {
@@ -151,6 +152,47 @@ func TestNewFeatureFlagsFromMap_EnterpriseHostAllowlist(t *testing.T) {
151152
}
152153
}
153154

155+
func TestNewFeatureFlagsFromMap_BlockPrivateInterceptorURLs(t *testing.T) {
156+
for _, tc := range []struct {
157+
name string
158+
data map[string]string
159+
want bool
160+
}{{
161+
name: "key absent defaults to false",
162+
data: map[string]string{},
163+
want: false,
164+
}, {
165+
name: "explicit true",
166+
data: map[string]string{
167+
"interceptors.block-private-interceptor-urls": "true",
168+
},
169+
want: true,
170+
}, {
171+
name: "explicit false",
172+
data: map[string]string{
173+
"interceptors.block-private-interceptor-urls": "false",
174+
},
175+
want: false,
176+
}, {
177+
name: "case insensitive True",
178+
data: map[string]string{
179+
"interceptors.block-private-interceptor-urls": "True",
180+
},
181+
want: true,
182+
}} {
183+
t.Run(tc.name, func(t *testing.T) {
184+
flags, err := config.NewFeatureFlagsFromMap(tc.data)
185+
if err != nil {
186+
t.Fatalf("NewFeatureFlagsFromMap() error = %v", err)
187+
}
188+
if flags.InterceptorsBlockPrivateInterceptorURLs != tc.want {
189+
t.Errorf("InterceptorsBlockPrivateInterceptorURLs = %v, want %v",
190+
flags.InterceptorsBlockPrivateInterceptorURLs, tc.want)
191+
}
192+
})
193+
}
194+
}
195+
154196
func verifyConfigFileWithExpectedFeatureFlagsConfig(t *testing.T, fileName string, expectedConfig *config.FeatureFlags) {
155197
cm := test.ConfigMapFromTestFile(t, fileName)
156198
if flags, err := config.NewFeatureFlagsFromConfigMap(cm); err == nil {

‎pkg/apis/config/testdata/feature-flags-all-flags-set.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ data:
2121
enable-api-fields: "alpha"
2222
labels-exclusion-pattern: "^abc-"
2323
interceptors.github.use-enterprise-host-allowlist: "true"
24+
interceptors.block-private-interceptor-urls: "true"

‎pkg/interceptors/interceptors.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,17 @@ func ResolveToURL(getter InterceptorGetter, name string) (*apis.URL, error) {
115115
return ic.ResolveAddress()
116116
}
117117

118-
func Execute(ctx context.Context, client *http.Client, req *triggersv1beta1.InterceptorRequest, url string) (*triggersv1beta1.InterceptorResponse, error) {
118+
// Execute dispatches req to the interceptor served at url and returns its
119+
// response. An optional URLValidator may be supplied to reject URLs that
120+
// resolve to disallowed address ranges (an SSRF guard); when omitted or
121+
// disabled, every URL is accepted, preserving the previous behaviour.
122+
func Execute(ctx context.Context, client *http.Client, req *triggersv1beta1.InterceptorRequest, url string, validator ...*URLValidator) (*triggersv1beta1.InterceptorResponse, error) {
123+
if len(validator) > 0 {
124+
if err := validator[0].Validate(url); err != nil {
125+
return nil, err
126+
}
127+
}
128+
119129
b, err := json.Marshal(req)
120130
if err != nil {
121131
return nil, err

‎pkg/interceptors/interceptors_test.go‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,3 +532,61 @@ func TestExecute_Error(t *testing.T) {
532532
})
533533
}
534534
}
535+
536+
func TestExecute_URLValidator(t *testing.T) {
537+
req := &triggersv1.InterceptorRequest{
538+
Header: http.Header(map[string][]string{
539+
"Content-Type": {"application/json"},
540+
}),
541+
InterceptorParams: map[string]interface{}{
542+
"filter": `header.match("Content-Type", "application/json")`,
543+
},
544+
Context: &triggersv1.TriggerContext{
545+
EventURL: "http://someurl.com",
546+
EventID: "abcde",
547+
TriggerID: "namespaces/default/triggers/test-trigger",
548+
},
549+
}
550+
551+
for _, tc := range []struct {
552+
name string
553+
url string
554+
validator *interceptors.URLValidator
555+
wantErr bool
556+
}{{
557+
name: "guard disabled allows private url",
558+
url: "http://127.0.0.1/cel",
559+
validator: &interceptors.URLValidator{BlockPrivate: false},
560+
wantErr: false,
561+
}, {
562+
name: "guard enabled blocks loopback url before dispatch",
563+
url: "http://127.0.0.1/cel",
564+
validator: &interceptors.URLValidator{BlockPrivate: true},
565+
wantErr: true,
566+
}, {
567+
name: "guard enabled blocks metadata url before dispatch",
568+
url: "http://169.254.169.254/latest/meta-data/",
569+
validator: &interceptors.URLValidator{BlockPrivate: true},
570+
wantErr: true,
571+
}, {
572+
name: "allowlisted loopback url is dispatched",
573+
url: "http://127.0.0.1/cel",
574+
validator: &interceptors.URLValidator{BlockPrivate: true, Allowlist: []string{"127.0.0.1"}},
575+
wantErr: false,
576+
}} {
577+
t.Run(tc.name, func(t *testing.T) {
578+
coreInterceptors, err := server.NewWithCoreInterceptors(nil, zaptest.NewLogger(t).Sugar(), nil)
579+
if err != nil {
580+
t.Fatalf("failed to initialize core interceptors: %v", err)
581+
}
582+
httpClient := testServer(t, coreInterceptors)
583+
_, err = interceptors.Execute(context.Background(), httpClient, req, tc.url, tc.validator)
584+
if tc.wantErr && err == nil {
585+
t.Fatalf("Execute() expected an error but got nil")
586+
}
587+
if !tc.wantErr && err != nil {
588+
t.Fatalf("Execute() unexpected error: %v", err)
589+
}
590+
})
591+
}
592+
}

0 commit comments

Comments
 (0)