Skip to content

Commit bbfb522

Browse files
committed
OCPBUGS-90080: Validate chart URL in /api/helm/verify to prevent SSRF
The `/api/helm/verify` endpoint accepted arbitrary chart_url values without validation, allowing authenticated users to make the backend issue requests to internal network addresses. I reused the existing IsValidChartURL check (already enforced on install and get-chart paths) to reject URLs that are not oci:// or http(s)://*.tgz before the backend attempts any connection. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41d3938 commit bbfb522

5 files changed

Lines changed: 75 additions & 7 deletions

File tree

pkg/helm/actions/get_chart.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func GetChart(url string, conf *action.Configuration, repositoryNamespace string
6767
// Secret in namespace with username and password keys when the registry requires authentication.
6868
func GetChartFromURL(url string, conf *action.Configuration, namespace string, client dynamic.Interface, coreClient corev1client.CoreV1Interface, filesCleanup bool, basicAuthSecretName string) (*chart.Chart, error) {
6969

70-
if !isValidChartURL(url) {
70+
if !IsValidChartURL(url) {
7171
return nil, fmt.Errorf("invalid chart URL: %s, must be oci:// URL or http(s)://*.tgz", url)
7272
}
7373
cmd := action.NewInstall(conf)

pkg/helm/actions/install_chart.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ type RegistryClientSetter interface {
5858
SetRegistryClient(rc *registry.Client)
5959
}
6060

61-
// isValidChartURL validates chart URLs using RFC-compliant hostname labels.
61+
// IsValidChartURL validates chart URLs using RFC-compliant hostname labels.
6262
// Accepts oci://<registry>/<path> and http(s)://<host>/<path>.tgz|tar.gz URLs.
63-
func isValidChartURL(raw string) bool {
63+
func IsValidChartURL(raw string) bool {
6464
return ociURLRe.MatchString(raw) || httpURLRe.MatchString(raw)
6565
}
6666

@@ -324,7 +324,7 @@ func addAuthSecretAnnotation(ch *chart.Chart, secretName string) {
324324
// basicAuthSecretName names a Secret in ns containing username and password keys for registry auth.
325325
func InstallChartFromURL(ns, name, url string, vals map[string]interface{}, conf *action.Configuration, coreClient corev1client.CoreV1Interface, version, basicAuthSecretName string) (*kv1.Secret, error) {
326326

327-
if !isValidChartURL(url) {
327+
if !IsValidChartURL(url) {
328328
return nil, fmt.Errorf("invalid chart URL: %s, must be oci:// URL or http(s)://*.tgz", url)
329329
}
330330

pkg/helm/actions/install_chart_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -663,9 +663,9 @@ func TestIsValidChartURL(t *testing.T) {
663663
}
664664
for _, tt := range tests {
665665
t.Run(tt.name, func(t *testing.T) {
666-
got := isValidChartURL(tt.url)
666+
got := IsValidChartURL(tt.url)
667667
if got != tt.valid {
668-
t.Errorf("isValidChartURL(%q) = %v, want %v", tt.url, got, tt.valid)
668+
t.Errorf("IsValidChartURL(%q) = %v, want %v", tt.url, got, tt.valid)
669669
}
670670
})
671671
}

pkg/helm/handlers/handlerChartVerifier.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package handlers
33
import (
44
"encoding/json"
55
"fmt"
6+
"net"
67
"net/http"
8+
"net/url"
79

810
"github.com/openshift/console/pkg/auth"
911
"github.com/openshift/console/pkg/helm/actions"
@@ -47,6 +49,28 @@ func (h *verifierHandlers) restConfig(bearerToken string) *rest.Config {
4749
Transport: h.Transport,
4850
}
4951
}
52+
func isPrivateURL(raw string) bool {
53+
u, err := url.Parse(raw)
54+
if err != nil {
55+
return true
56+
}
57+
host := u.Hostname()
58+
ip := net.ParseIP(host)
59+
if ip == nil {
60+
ips, err := net.LookupIP(host)
61+
if err != nil || len(ips) == 0 {
62+
return true
63+
}
64+
for _, ip := range ips {
65+
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
66+
return true
67+
}
68+
}
69+
return false
70+
}
71+
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()
72+
}
73+
5074
func (h *verifierHandlers) HandleChartVerifier(user *auth.User, w http.ResponseWriter, r *http.Request) {
5175
var req HelmVerifierRequest
5276

@@ -55,6 +79,15 @@ func (h *verifierHandlers) HandleChartVerifier(user *auth.User, w http.ResponseW
5579
serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: fmt.Sprintf("Failed to parse request: %v", err)})
5680
return
5781
}
82+
if !actions.IsValidChartURL(req.ChartUrl) {
83+
serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: "invalid chart URL: must be oci:// or http(s)://*.tgz"})
84+
return
85+
}
86+
if isPrivateURL(req.ChartUrl) {
87+
serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: "chart URL must not point to a private or loopback address"})
88+
return
89+
}
90+
5891
conf := h.getActionConfigurations(h.ApiServerHost, "default", user.Token, &h.Transport)
5992
resp, err := h.chartVerifier(req.ChartUrl, req.Values, conf)
6093
if err != nil {

pkg/helm/handlers/handler_chartVerifier_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,26 @@ func fakeChartVerification(reportSummary string, err error) func(chartUrl string
2525
}
2626
}
2727
func TestHelmHandlers_HandleChartVerifier(t *testing.T) {
28+
validBody := `{"chart_url":"https://example.com/charts/mychart-1.0.0.tgz"}`
29+
2830
tests := []struct {
2931
name string
32+
body string
3033
expectedResponse string
3134
ReportSummary string
3235
error
3336
httpStatusCode int
3437
}{
3538
{
3639
name: "Error occurred",
40+
body: validBody,
3741
expectedResponse: `{"error":"Failed to verify chart: Chart path is invalid"}`,
3842
error: errors.New("Chart path is invalid"),
3943
httpStatusCode: http.StatusBadGateway,
4044
},
4145
{
4246
name: "Successful chart verification",
47+
body: validBody,
4348
ReportSummary: fakeReportSummary,
4449
httpStatusCode: http.StatusOK,
4550
expectedResponse: ``,
@@ -50,7 +55,7 @@ func TestHelmHandlers_HandleChartVerifier(t *testing.T) {
5055
handlers := fakeVerifierHandler()
5156
handlers.chartVerifier = fakeChartVerification(tt.ReportSummary, tt.error)
5257

53-
request := httptest.NewRequest("", "/foo", strings.NewReader("{}"))
58+
request := httptest.NewRequest("", "/foo", strings.NewReader(tt.body))
5459
response := httptest.NewRecorder()
5560

5661
handlers.HandleChartVerifier(&auth.User{}, response, request)
@@ -66,3 +71,33 @@ func TestHelmHandlers_HandleChartVerifier(t *testing.T) {
6671
})
6772
}
6873
}
74+
75+
func TestHelmHandlers_HandleChartVerifier_RejectsInvalidURLs(t *testing.T) {
76+
tests := []struct {
77+
name string
78+
body string
79+
}{
80+
{"rejects non-tgz HTTP URL", `{"chart_url":"http://example.com/charts/mychart"}`},
81+
{"rejects empty chart_url", `{"chart_url":""}`},
82+
{"rejects ftp scheme", `{"chart_url":"ftp://example.com/chart.tgz"}`},
83+
{"rejects file scheme", `{"chart_url":"file:///etc/passwd"}`},
84+
{"rejects private IP with tgz", `{"chart_url":"http://172.28.1.76:8849/evil.tgz"}`},
85+
{"rejects loopback", `{"chart_url":"http://127.0.0.1/chart.tgz"}`},
86+
{"rejects localhost", `{"chart_url":"http://localhost/chart.tgz"}`},
87+
{"rejects IPv6 loopback", `{"chart_url":"http://[::1]/chart.tgz"}`},
88+
}
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
handlers := fakeVerifierHandler()
92+
handlers.chartVerifier = fakeChartVerification("", nil)
93+
94+
request := httptest.NewRequest("POST", "/api/helm/verify", strings.NewReader(tt.body))
95+
response := httptest.NewRecorder()
96+
97+
handlers.HandleChartVerifier(&auth.User{}, response, request)
98+
if response.Code != http.StatusBadRequest {
99+
t.Errorf("expected status 400 but got %v", response.Code)
100+
}
101+
})
102+
}
103+
}

0 commit comments

Comments
 (0)