Skip to content

Commit 552b6d1

Browse files
authored
[COST-7998] Restrict prometheus service_address to in-cluster .svc urls (#1050)
* [COST-7998] restrict prometheus service_address to in-cluster .svc urls * clean up comment * restrict prometheus service_address to thanos-querier
1 parent 06a2a85 commit 552b6d1

3 files changed

Lines changed: 171 additions & 4 deletions

File tree

internal/collector/prometheus.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import (
99
"context"
1010
"fmt"
1111
"math"
12+
"net/url"
1213
"os"
1314
"path/filepath"
1415
"reflect"
16+
"strings"
1517
"time"
1618

1719
promapi "github.com/prometheus/client_golang/api"
@@ -89,7 +91,35 @@ func statusHelper(cr *metricscfgv1beta1.MetricsConfig, status int, err error) {
8991

9092
type PrometheusConfigurationSetter func(ps *metricscfgv1beta1.PrometheusSpec, c *PrometheusCollector) error
9193

94+
const (
95+
thanosQuerierSvcHost = "thanos-querier.openshift-monitoring.svc"
96+
thanosQuerierSvcClusterLocalHost = "thanos-querier.openshift-monitoring.svc.cluster.local"
97+
)
98+
99+
// IsAllowedPromSvcAddress reports whether address is an https URL for the
100+
// OpenShift cluster-monitoring thanos-querier Service.
101+
func IsAllowedPromSvcAddress(address string) bool {
102+
u, err := url.Parse(address)
103+
if err != nil || u.Host == "" {
104+
return false
105+
}
106+
if !strings.EqualFold(u.Scheme, "https") {
107+
return false
108+
}
109+
// DNS hostnames are case-insensitive; normalize before exact host checks.
110+
switch strings.ToLower(u.Hostname()) {
111+
case thanosQuerierSvcHost, thanosQuerierSvcClusterLocalHost:
112+
return true
113+
default:
114+
return false
115+
}
116+
}
117+
92118
func SetPrometheusConfig(ps *metricscfgv1beta1.PrometheusSpec, c *PrometheusCollector) error {
119+
// Validate before reading the SA token so a rejected address never loads credentials.
120+
if !IsAllowedPromSvcAddress(ps.SvcAddress) {
121+
return fmt.Errorf("service_address must be the OpenShift thanos-querier service (%s or %s); got %q", thanosQuerierSvcHost, thanosQuerierSvcClusterLocalHost, ps.SvcAddress)
122+
}
93123

94124
pCfg := &PrometheusConfig{
95125
Address: ps.SvcAddress,

internal/collector/prometheus_test.go

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path/filepath"
1313
"reflect"
14+
"strings"
1415
"testing"
1516
"time"
1617

@@ -25,6 +26,7 @@ import (
2526
var trueDef = true
2627
var falseDef = false
2728
var defaultContextTimeout = 90 * time.Second
29+
var promSvcAddress = metricscfgv1beta1.DefaultPrometheusSvcAddress
2830

2931
type mappedMockPromResult map[string]*mockPromResult
3032
type mockPromResult struct {
@@ -558,6 +560,7 @@ func TestGetPromConn(t *testing.T) {
558560
statusHelper(cr, statusConfiguration, tt.cfgErr)
559561
statusHelper(cr, statusConnection, tt.conErr)
560562
cr.Spec.PrometheusConfig.SkipTLSVerification = &trueDef
563+
cr.Spec.PrometheusConfig.SvcAddress = promSvcAddress
561564
c := &PrometheusCollector{
562565
PromConn: tt.con,
563566
PromCfg: tt.cfg,
@@ -575,10 +578,102 @@ func TestGetPromConn(t *testing.T) {
575578
}
576579
}
577580

581+
func TestIsAllowedPrometheusServiceAddress(t *testing.T) {
582+
tests := []struct {
583+
name string
584+
address string
585+
want bool
586+
}{
587+
{
588+
name: "default thanos-querier .svc address",
589+
address: promSvcAddress,
590+
want: true,
591+
},
592+
{
593+
name: "thanos-querier with cluster.local suffix",
594+
address: "https://thanos-querier.openshift-monitoring.svc.cluster.local:9091",
595+
want: true,
596+
},
597+
{
598+
name: "thanos-querier without explicit port",
599+
address: "https://thanos-querier.openshift-monitoring.svc",
600+
want: true,
601+
},
602+
{
603+
name: "address with trailing slash",
604+
address: "https://thanos-querier.openshift-monitoring.svc:9091/",
605+
want: true,
606+
},
607+
{
608+
name: "uppercase scheme and hostname are allowed",
609+
address: "HTTPS://THANOS-QUERIER.OPENSHIFT-MONITORING.SVC:9091",
610+
want: true,
611+
},
612+
{
613+
name: "mixed-case .svc.cluster.local hostname is allowed",
614+
address: "https://Thanos-Querier.OpenShift-Monitoring.SVC.Cluster.Local:9091",
615+
want: true,
616+
},
617+
{
618+
name: "custom in-cluster prometheus service rejected",
619+
address: "https://prometheus.custom-monitoring.svc:9090",
620+
want: false,
621+
},
622+
{
623+
name: "evil service in openshift-monitoring rejected",
624+
address: "https://evil.openshift-monitoring.svc:9091",
625+
want: false,
626+
},
627+
{
628+
name: "other service in openshift-monitoring rejected",
629+
address: "https://prometheus-k8s.openshift-monitoring.svc:9091",
630+
want: false,
631+
},
632+
{
633+
name: "CRC route address",
634+
address: "https://thanos-querier-openshift-monitoring.apps-crc.testing",
635+
want: false,
636+
},
637+
{
638+
name: "external malicious URL",
639+
address: "https://evil.example.com",
640+
want: false,
641+
},
642+
{
643+
name: "http scheme rejected",
644+
address: "http://thanos-querier.openshift-monitoring.svc:9091",
645+
want: false,
646+
},
647+
{
648+
name: "empty address",
649+
address: "",
650+
want: false,
651+
},
652+
{
653+
name: "hostname that contains svc but is not thanos-querier",
654+
address: "https://evil.svc.attacker.com",
655+
want: false,
656+
},
657+
{
658+
name: "IP address rejected",
659+
address: "https://192.168.1.10:9091",
660+
want: false,
661+
},
662+
}
663+
for _, tt := range tests {
664+
t.Run(tt.name, func(t *testing.T) {
665+
got := IsAllowedPromSvcAddress(tt.address)
666+
if got != tt.want {
667+
t.Errorf("IsAllowedPromSvcAddress(%q) = %v, want %v", tt.address, got, tt.want)
668+
}
669+
})
670+
}
671+
}
672+
578673
func TestSetPrometheusConfig(t *testing.T) {
579674
trueDef := true
580675
ps := &metricscfgv1beta1.PrometheusSpec{
581-
SvcAddress: "svc-address",
676+
SvcAddress: promSvcAddress,
582677
SkipTLSVerification: &trueDef,
583678
}
584679
secretsPath := "./test_files/test_secrets"
@@ -588,6 +683,7 @@ func TestSetPrometheusConfig(t *testing.T) {
588683
basePath string
589684
certKey bool
590685
tokenKey bool
686+
address string
591687
want *PrometheusConfig
592688
wantedError error
593689
}{
@@ -597,7 +693,7 @@ func TestSetPrometheusConfig(t *testing.T) {
597693
certKey: true,
598694
tokenKey: true,
599695
want: &PrometheusConfig{
600-
Address: "svc-address",
696+
Address: promSvcAddress,
601697
SkipTLS: true,
602698
BearerToken: config.Secret([]byte("this-is-token-data")),
603699
CAFile: filepath.Join(secretsPath, certKey),
@@ -618,7 +714,7 @@ func TestSetPrometheusConfig(t *testing.T) {
618714
certKey: true,
619715
tokenKey: true,
620716
want: &PrometheusConfig{
621-
Address: "svc-address",
717+
Address: promSvcAddress,
622718
SkipTLS: true,
623719
BearerToken: config.Secret([]byte("this-is-token-data")),
624720
CAFile: filepath.Join(secretsPath, certKey),
@@ -640,6 +736,14 @@ func TestSetPrometheusConfig(t *testing.T) {
640736
want: nil,
641737
wantedError: errTest,
642738
},
739+
{
740+
name: "rejected external service_address does not require token",
741+
basePath: "",
742+
tokenKey: false,
743+
address: "https://evil.example.com",
744+
want: nil,
745+
wantedError: errTest,
746+
},
643747
}
644748
for _, tt := range setPrometheusConfigTests {
645749
t.Run(tt.name, func(t *testing.T) {
@@ -654,14 +758,21 @@ func TestSetPrometheusConfig(t *testing.T) {
654758
os.Remove(filepath.Join(tt.basePath, tokenKey))
655759
os.Remove(filepath.Join(tt.basePath, certKey))
656760
}()
657-
err := SetPrometheusConfig(ps, c)
761+
spec := *ps
762+
if tt.address != "" {
763+
spec.SvcAddress = tt.address
764+
}
765+
err := SetPrometheusConfig(&spec, c)
658766
got := c.PromCfg
659767
if tt.wantedError == nil && err != nil {
660768
t.Errorf("%s got unexpected error: %v", tt.name, err)
661769
}
662770
if tt.wantedError != nil && err == nil {
663771
t.Errorf("%s expected error, got %v", tt.name, err)
664772
}
773+
if tt.address != "" && err != nil && !strings.Contains(err.Error(), "thanos-querier") {
774+
t.Errorf("%s expected thanos-querier validation error, got %v", tt.name, err)
775+
}
665776
if got != nil && !reflect.DeepEqual(*got, *tt.want) {
666777
t.Errorf("%s got %+v want %+v", tt.name, got, tt.want)
667778
}

internal/controller/costmanagementmetricsconfig_controller_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,32 @@ var _ = Describe("MetricsConfigController - CRD Handling", Ordered, func() {
13381338

13391339
Expect(fetched.Status.Prometheus.ConfigError).To(ContainSubstring("failed to get token"))
13401340
})
1341+
It("rejects external service_address before querying prometheus", func() {
1342+
resetReconciler(WithSecretOverride(os.Getenv("SECRET_ABSPATH")))
1343+
1344+
t := time.Now().UTC().Truncate(1 * time.Hour).Add(-1 * time.Hour)
1345+
timeRange := promv1.Range{
1346+
Start: t,
1347+
End: t.Add(59*time.Minute + 59*time.Second),
1348+
Step: time.Minute,
1349+
}
1350+
mockpconn.EXPECT().QueryRange(gomock.Any(), gomock.Any(), timeRange, gomock.Any()).Return(model.Matrix{}, nil, nil).Times(0)
1351+
1352+
instCopy.Spec.Upload.UploadToggle = &falseValue
1353+
instCopy.Spec.PrometheusConfig.SvcAddress = "https://evil.example.com"
1354+
createObject(ctx, instCopy)
1355+
1356+
fetched := &metricscfgv1beta1.MetricsConfig{}
1357+
1358+
Eventually(func() bool {
1359+
_ = k8sClient.Get(ctx, types.NamespacedName{Name: instCopy.Name, Namespace: namespace}, fetched)
1360+
return fetched.Status.Prometheus.ConfigError != ""
1361+
}, timeout, interval).Should(BeTrue())
1362+
1363+
Expect(fetched.Status.Prometheus.PrometheusConfigured).To(BeFalse())
1364+
Expect(fetched.Status.Prometheus.ConfigError).To(ContainSubstring("thanos-querier"))
1365+
Expect(fetched.Status.Prometheus.ConfigError).To(ContainSubstring("evil.example.com"))
1366+
})
13411367
It("successfully queried but there was no data", func() {
13421368
resetReconciler(WithSecretOverride(os.Getenv("SECRET_ABSPATH")))
13431369

0 commit comments

Comments
 (0)