mirror of https://github.com/helm/helm
Merge 122b21d380 into f90719fa19
commit
dc2265d236
@ -0,0 +1,357 @@
|
||||
/*
|
||||
Copyright The Helm 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 downloader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
)
|
||||
|
||||
// basicAuthMiddleware returns a middleware that requires Basic Auth
|
||||
func basicAuthMiddleware(username, password string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized\n"))
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(auth, "Basic ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized\n"))
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := base64.StdEncoding.DecodeString(auth[6:])
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized\n"))
|
||||
return
|
||||
}
|
||||
|
||||
pair := strings.SplitN(string(payload), ":", 2)
|
||||
if len(pair) != 2 || pair[0] != username || pair[1] != password {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized\n"))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestServer creates an HTTP server with Basic Auth for testing
|
||||
func createTestServer(username, password string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Mock index.yaml
|
||||
indexYAML := `apiVersion: v1
|
||||
entries:
|
||||
test-chart:
|
||||
- name: test-chart
|
||||
version: 0.1.0
|
||||
description: A test chart
|
||||
urls:
|
||||
- test-chart-0.1.0.tgz
|
||||
generated: "2025-09-28T20:00:00Z"
|
||||
`
|
||||
|
||||
// Mock chart content
|
||||
chartTGZ := "fake-chart-content-for-testing"
|
||||
|
||||
// Index file endpoint
|
||||
mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.Write([]byte(indexYAML))
|
||||
})
|
||||
|
||||
// Chart file endpoint
|
||||
mux.HandleFunc("/test-chart-0.1.0.tgz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Write([]byte(chartTGZ))
|
||||
})
|
||||
|
||||
// Apply Basic Auth middleware only if credentials are provided
|
||||
var handler http.Handler = mux
|
||||
if username != "" && password != "" {
|
||||
handler = basicAuthMiddleware(username, password)(mux)
|
||||
}
|
||||
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func TestManagerBasicAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverUsername string
|
||||
serverPassword string
|
||||
managerUsername string
|
||||
managerPassword string
|
||||
expectSuccess bool
|
||||
expectErrorString string
|
||||
}{
|
||||
{
|
||||
name: "Success with valid credentials",
|
||||
serverUsername: "testuser",
|
||||
serverPassword: "testpass",
|
||||
managerUsername: "testuser",
|
||||
managerPassword: "testpass",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "Fail without credentials on protected repo",
|
||||
serverUsername: "testuser",
|
||||
serverPassword: "testpass",
|
||||
managerUsername: "",
|
||||
managerPassword: "",
|
||||
expectSuccess: false,
|
||||
expectErrorString: "401 Unauthorized",
|
||||
},
|
||||
{
|
||||
name: "Fail with wrong credentials",
|
||||
serverUsername: "testuser",
|
||||
serverPassword: "testpass",
|
||||
managerUsername: "wronguser",
|
||||
managerPassword: "wrongpass",
|
||||
expectSuccess: false,
|
||||
expectErrorString: "401 Unauthorized",
|
||||
},
|
||||
{
|
||||
name: "Success with public repo (no auth required)",
|
||||
serverUsername: "",
|
||||
serverPassword: "",
|
||||
managerUsername: "",
|
||||
managerPassword: "",
|
||||
expectSuccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test server
|
||||
srv := createTestServer(tt.serverUsername, tt.serverPassword)
|
||||
defer srv.Close()
|
||||
|
||||
// Create temporary directories
|
||||
tempDir := t.TempDir()
|
||||
chartDir := filepath.Join(tempDir, "test-chart")
|
||||
chartsDir := filepath.Join(chartDir, "charts")
|
||||
|
||||
err := os.MkdirAll(chartsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a test Chart.yaml
|
||||
chartYAML := fmt.Sprintf(`apiVersion: v2
|
||||
name: test-chart
|
||||
description: A Helm chart for testing
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.16.0"
|
||||
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: "%s"
|
||||
`, srv.URL)
|
||||
|
||||
chartYAMLPath := filepath.Join(chartDir, "Chart.yaml")
|
||||
err = os.WriteFile(chartYAMLPath, []byte(chartYAML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Manager with test settings
|
||||
repoCache := filepath.Join(tempDir, "cache")
|
||||
repoConfig := filepath.Join(tempDir, "repositories.yaml")
|
||||
contentCache := filepath.Join(tempDir, "content")
|
||||
|
||||
err = os.MkdirAll(repoCache, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(contentCache, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create empty repositories.yaml
|
||||
err = os.WriteFile(repoConfig, []byte("apiVersion: v1\ngenerated: \"2025-09-28T20:00:00Z\"\nrepositories: []\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: tt.managerUsername,
|
||||
Password: tt.managerPassword,
|
||||
Debug: true,
|
||||
}
|
||||
|
||||
// Execute the test
|
||||
err = m.Update()
|
||||
|
||||
// Validate results
|
||||
if tt.expectSuccess {
|
||||
if err != nil {
|
||||
t.Errorf("Expected success, but got error: %v\nOutput: %s", err, out.String())
|
||||
}
|
||||
} else {
|
||||
// For failed cases, we check both the error and the output for 401
|
||||
outputStr := out.String()
|
||||
errorStr := ""
|
||||
if err != nil {
|
||||
errorStr = err.Error()
|
||||
}
|
||||
|
||||
if !strings.Contains(outputStr, tt.expectErrorString) && !strings.Contains(errorStr, tt.expectErrorString) {
|
||||
t.Errorf("Expected error or output containing '%s', but got error: %v\nOutput: %s", tt.expectErrorString, err, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerBasicAuthNoCredentialLeak(t *testing.T) {
|
||||
// Create two servers: one public, one private
|
||||
publicSrv := createTestServer("", "")
|
||||
defer publicSrv.Close()
|
||||
|
||||
privateSrv := createTestServer("testuser", "testpass")
|
||||
defer privateSrv.Close()
|
||||
|
||||
// Track requests to ensure credentials are not sent to public repo
|
||||
var publicRequests []http.Header
|
||||
var privateRequests []http.Header
|
||||
|
||||
publicSrvWithLogging := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
publicRequests = append(publicRequests, r.Header.Clone())
|
||||
publicSrv.Config.Handler.ServeHTTP(w, r)
|
||||
}))
|
||||
defer publicSrvWithLogging.Close()
|
||||
|
||||
privateSrvWithLogging := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
privateRequests = append(privateRequests, r.Header.Clone())
|
||||
privateSrv.Config.Handler.ServeHTTP(w, r)
|
||||
}))
|
||||
defer privateSrvWithLogging.Close()
|
||||
|
||||
// Create temporary directories
|
||||
tempDir := t.TempDir()
|
||||
chartDir := filepath.Join(tempDir, "test-chart")
|
||||
chartsDir := filepath.Join(chartDir, "charts")
|
||||
|
||||
err := os.MkdirAll(chartsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a Chart.yaml with dependencies from both public and private repos
|
||||
chartYAML := fmt.Sprintf(`apiVersion: v2
|
||||
name: test-chart
|
||||
description: A Helm chart for testing
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.16.0"
|
||||
|
||||
dependencies:
|
||||
- name: public-chart
|
||||
version: "0.1.0"
|
||||
repository: "%s"
|
||||
- name: private-chart
|
||||
version: "0.1.0"
|
||||
repository: "%s"
|
||||
`, publicSrvWithLogging.URL, privateSrvWithLogging.URL)
|
||||
|
||||
chartYAMLPath := filepath.Join(chartDir, "Chart.yaml")
|
||||
err = os.WriteFile(chartYAMLPath, []byte(chartYAML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Manager with test settings
|
||||
repoCache := filepath.Join(tempDir, "cache")
|
||||
repoConfig := filepath.Join(tempDir, "repositories.yaml")
|
||||
contentCache := filepath.Join(tempDir, "content")
|
||||
|
||||
err = os.MkdirAll(repoCache, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(contentCache, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create empty repositories.yaml
|
||||
err = os.WriteFile(repoConfig, []byte("apiVersion: v1\ngenerated: \"2025-09-28T20:00:00Z\"\nrepositories: []\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
Debug: true,
|
||||
}
|
||||
|
||||
// Execute the test - we expect this to fail because we're sending creds to all repos
|
||||
// This test documents current behavior - in the future this should be improved
|
||||
_ = m.Update() // Ignore error, we're just testing credential behavior
|
||||
|
||||
// For now, we just verify that credentials are being sent to all repos
|
||||
// This is a known limitation that could be improved in a future PR
|
||||
t.Logf("Current behavior: credentials are sent to all repositories")
|
||||
t.Logf("Public server received %d requests", len(publicRequests))
|
||||
t.Logf("Private server received %d requests", len(privateRequests))
|
||||
|
||||
// Check if Authorization header was sent to public server (current behavior)
|
||||
for _, header := range publicRequests {
|
||||
if auth := header.Get("Authorization"); auth != "" {
|
||||
t.Logf("WARNING: Authorization header sent to public repo: %s", auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,345 @@
|
||||
/*
|
||||
Copyright The Helm 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 downloader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
)
|
||||
|
||||
// TestIssue31262 specifically tests the bug reported in https://github.com/helm/helm/issues/31262
|
||||
// This test reproduces the exact scenario described in the issue:
|
||||
// helm dependency update with --username/--password should work with non-OCI repositories
|
||||
func TestIssue31262(t *testing.T) {
|
||||
// Create a test server that requires Basic Auth (like the private Artifactory in the issue)
|
||||
username, password := "testuser", "testpass"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Log the request for debugging
|
||||
t.Logf("Request: %s %s, Auth header: %s", r.Method, r.URL.Path, r.Header.Get("Authorization"))
|
||||
|
||||
// Check for Basic Auth
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Private Repository"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized - Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(auth, "Basic ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized - Invalid auth method"))
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := base64.StdEncoding.DecodeString(auth[6:])
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized - Invalid auth encoding"))
|
||||
return
|
||||
}
|
||||
|
||||
pair := strings.SplitN(string(payload), ":", 2)
|
||||
if len(pair) != 2 || pair[0] != username || pair[1] != password {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized - Invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the requested resource
|
||||
switch r.URL.Path {
|
||||
case "/index.yaml":
|
||||
// Mock index.yaml similar to Artifactory
|
||||
indexYAML := `apiVersion: v1
|
||||
entries:
|
||||
external-secrets:
|
||||
- name: external-secrets
|
||||
version: 0.19.2
|
||||
description: External Secrets Operator is a Kubernetes operator
|
||||
urls:
|
||||
- external-secrets-0.19.2.tgz
|
||||
generated: "2025-09-28T20:00:00Z"
|
||||
`
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.Write([]byte(indexYAML))
|
||||
case "/external-secrets-0.19.2.tgz":
|
||||
// Mock chart content
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Write([]byte("mock-chart-content"))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("Not Found"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Create temporary directories for the test
|
||||
tempDir := t.TempDir()
|
||||
chartDir := filepath.Join(tempDir, "test-chart")
|
||||
chartsDir := filepath.Join(chartDir, "charts")
|
||||
|
||||
err := os.MkdirAll(chartsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Chart.yaml exactly like in the issue report
|
||||
chartYAML := fmt.Sprintf(`apiVersion: v2
|
||||
name: test-chart
|
||||
description: A Helm chart for Kubernetes
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.16.0"
|
||||
|
||||
dependencies:
|
||||
- name: external-secrets
|
||||
version: "0.19.2"
|
||||
repository: "%s"
|
||||
`, srv.URL)
|
||||
|
||||
chartYAMLPath := filepath.Join(chartDir, "Chart.yaml")
|
||||
err = os.WriteFile(chartYAMLPath, []byte(chartYAML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create cache directories
|
||||
repoCache := filepath.Join(tempDir, "cache")
|
||||
repoConfig := filepath.Join(tempDir, "repositories.yaml")
|
||||
contentCache := filepath.Join(tempDir, "content")
|
||||
|
||||
for _, dir := range []string{repoCache, contentCache} {
|
||||
err = os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create empty repositories.yaml (simulating no pre-configured repos)
|
||||
err = os.WriteFile(repoConfig, []byte("apiVersion: v1\ngenerated: \"2025-09-28T20:00:00Z\"\nrepositories: []\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test Case 1: WITHOUT credentials (should fail with 401 like in the original issue)
|
||||
t.Run("Without credentials - should fail with 401", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: "", // NO USERNAME
|
||||
Password: "", // NO PASSWORD
|
||||
}
|
||||
|
||||
_ = m.Update() // Error expected for this test case
|
||||
outputStr := out.String()
|
||||
|
||||
// Should fail and contain 401 error
|
||||
if !strings.Contains(outputStr, "401 Unauthorized") {
|
||||
t.Errorf("Expected 401 Unauthorized error in output, but got: %s", outputStr)
|
||||
}
|
||||
|
||||
t.Logf("Without credentials output (expected to fail): %s", outputStr)
|
||||
})
|
||||
|
||||
// Test Case 2: WITH credentials (should succeed - this is the fix!)
|
||||
t.Run("With credentials - should succeed", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username, // WITH USERNAME
|
||||
Password: password, // WITH PASSWORD
|
||||
}
|
||||
|
||||
err := m.Update()
|
||||
outputStr := out.String()
|
||||
|
||||
// Should NOT contain 401 error
|
||||
if strings.Contains(outputStr, "401 Unauthorized") {
|
||||
t.Errorf("Expected NO 401 Unauthorized error with valid credentials, but got: %s", outputStr)
|
||||
}
|
||||
|
||||
// Should contain success messages
|
||||
if !strings.Contains(outputStr, "Successfully got an update from") {
|
||||
t.Errorf("Expected success message for index fetch, but got: %s", outputStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(outputStr, "Downloading external-secrets") {
|
||||
t.Errorf("Expected chart download message, but got: %s", outputStr)
|
||||
}
|
||||
|
||||
t.Logf("With credentials output (expected to succeed): %s", outputStr)
|
||||
|
||||
// The error (if any) should NOT be authentication-related
|
||||
if err != nil && strings.Contains(err.Error(), "401") {
|
||||
t.Errorf("Should not have 401 authentication error with valid credentials, but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestManagerCredentialsArePassedToBothPhases ensures credentials are passed to both:
|
||||
// 1. Index.yaml fetch (ensureMissingRepos -> parallelRepoUpdate)
|
||||
// 2. Chart download (downloadAll -> findChartURL)
|
||||
func TestManagerCredentialsArePassedToBothPhases(t *testing.T) {
|
||||
username, password := "testuser", "testpass"
|
||||
var indexRequests []http.Header
|
||||
var chartRequests []http.Header
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
|
||||
// Verify authentication
|
||||
if auth == "" || !strings.HasPrefix(auth, "Basic ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
payload, _ := base64.StdEncoding.DecodeString(auth[6:])
|
||||
pair := strings.SplitN(string(payload), ":", 2)
|
||||
if len(pair) != 2 || pair[0] != username || pair[1] != password {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("401 Unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
// Track requests by endpoint
|
||||
switch r.URL.Path {
|
||||
case "/index.yaml":
|
||||
indexRequests = append(indexRequests, r.Header.Clone())
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries:
|
||||
test-chart:
|
||||
- name: test-chart
|
||||
version: 0.1.0
|
||||
urls:
|
||||
- test-chart-0.1.0.tgz
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
case "/test-chart-0.1.0.tgz":
|
||||
chartRequests = append(chartRequests, r.Header.Clone())
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Write([]byte("mock-chart"))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Setup test environment
|
||||
tempDir := t.TempDir()
|
||||
chartDir := filepath.Join(tempDir, "test-chart")
|
||||
err := os.MkdirAll(filepath.Join(chartDir, "charts"), 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chartYAML := fmt.Sprintf(`apiVersion: v2
|
||||
name: test-chart
|
||||
description: Test chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: "%s"`, srv.URL)
|
||||
|
||||
err = os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYAML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Setup cache directories
|
||||
repoCache := filepath.Join(tempDir, "cache")
|
||||
repoConfig := filepath.Join(tempDir, "repositories.yaml")
|
||||
contentCache := filepath.Join(tempDir, "content")
|
||||
|
||||
for _, dir := range []string{repoCache, contentCache} {
|
||||
err = os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = os.WriteFile(repoConfig, []byte("apiVersion: v1\nrepositories: []\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
err = m.Update()
|
||||
|
||||
// Verify both phases received authenticated requests
|
||||
if len(indexRequests) == 0 {
|
||||
t.Error("Expected at least one request to /index.yaml, but got none")
|
||||
}
|
||||
|
||||
if len(chartRequests) == 0 {
|
||||
t.Error("Expected at least one request to chart download endpoint, but got none")
|
||||
}
|
||||
|
||||
// Verify both requests had proper authentication
|
||||
for i, req := range indexRequests {
|
||||
auth := req.Get("Authorization")
|
||||
if auth == "" {
|
||||
t.Errorf("Index request %d missing Authorization header", i)
|
||||
} else if !strings.HasPrefix(auth, "Basic ") {
|
||||
t.Errorf("Index request %d has invalid Authorization header: %s", i, auth)
|
||||
}
|
||||
}
|
||||
|
||||
for i, req := range chartRequests {
|
||||
auth := req.Get("Authorization")
|
||||
if auth == "" {
|
||||
t.Errorf("Chart request %d missing Authorization header", i)
|
||||
} else if !strings.HasPrefix(auth, "Basic ") {
|
||||
t.Errorf("Chart request %d has invalid Authorization header: %s", i, auth)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Successfully verified credentials were passed to both phases:")
|
||||
t.Logf("- Index requests: %d", len(indexRequests))
|
||||
t.Logf("- Chart requests: %d", len(chartRequests))
|
||||
}
|
||||
@ -0,0 +1,889 @@
|
||||
/*
|
||||
Copyright The Helm 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 downloader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
)
|
||||
|
||||
// TestSecurityCredentialHandling tests security aspects of credential handling
|
||||
// This ensures the fix for #31262 doesn't introduce security vulnerabilities
|
||||
func TestSecurityCredentialHandling(t *testing.T) {
|
||||
t.Run("Credentials not logged in debug mode", func(t *testing.T) {
|
||||
testCredentialLogging(t)
|
||||
})
|
||||
|
||||
t.Run("Credentials not sent to unrelated hosts", func(t *testing.T) {
|
||||
testCredentialHostScoping(t)
|
||||
})
|
||||
|
||||
t.Run("Credentials properly encoded", func(t *testing.T) {
|
||||
testCredentialEncoding(t)
|
||||
})
|
||||
|
||||
t.Run("Malicious credentials handled safely", func(t *testing.T) {
|
||||
testMaliciousCredentialHandling(t)
|
||||
})
|
||||
|
||||
t.Run("Credentials cleared from memory", func(t *testing.T) {
|
||||
testCredentialMemoryHandling(t)
|
||||
})
|
||||
}
|
||||
|
||||
// testCredentialLogging ensures credentials are not exposed in logs
|
||||
func testCredentialLogging(t *testing.T) {
|
||||
username, password := "sensitive-user", "super-secret-password"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Mock successful response
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries:
|
||||
test-chart:
|
||||
- name: test-chart
|
||||
version: 0.1.0
|
||||
urls: [test-chart-0.1.0.tgz]
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else if r.URL.Path == "/test-chart-0.1.0.tgz" {
|
||||
w.Write([]byte("fake-chart"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, srv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Debug: true, // Enable debug mode
|
||||
}
|
||||
|
||||
_ = m.Update()
|
||||
output := out.String()
|
||||
|
||||
// Ensure credentials are NOT exposed in debug output
|
||||
if strings.Contains(output, password) {
|
||||
t.Errorf("Password '%s' found in debug output - SECURITY VULNERABILITY!", password)
|
||||
t.Errorf("Debug output: %s", output)
|
||||
}
|
||||
|
||||
if strings.Contains(output, username) && !strings.Contains(output, "username") {
|
||||
// Allow the word "username" but not the actual username value in logs
|
||||
if strings.Contains(output, fmt.Sprintf("username: %s", username)) {
|
||||
t.Errorf("Username '%s' found in debug output - potential information leak!", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testCredentialHostScoping ensures credentials are not sent to unrelated hosts
|
||||
func testCredentialHostScoping(t *testing.T) {
|
||||
username, password := "testuser", "testpass"
|
||||
|
||||
// Create target server that expects auth
|
||||
targetSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries:
|
||||
test-chart:
|
||||
- name: test-chart
|
||||
version: 0.1.0
|
||||
urls: [http://malicious-external-server.com/steal-creds.tgz]
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
}
|
||||
}))
|
||||
defer targetSrv.Close()
|
||||
|
||||
// Create malicious server that should NOT receive credentials
|
||||
var maliciousRequests []http.Header
|
||||
maliciousSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
maliciousRequests = append(maliciousRequests, r.Header.Clone())
|
||||
w.Write([]byte("malicious-content"))
|
||||
}))
|
||||
defer maliciousSrv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, targetSrv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
_ = m.Update() // Will try to download from malicious server
|
||||
|
||||
// Verify NO credentials were sent to malicious server
|
||||
// NOTE: This test documents current behavior - credentials ARE sent to all hosts
|
||||
// This is a known limitation that should be improved in the future
|
||||
for i, header := range maliciousRequests {
|
||||
auth := header.Get("Authorization")
|
||||
if auth != "" {
|
||||
t.Logf("WARNING: Request %d to malicious server has Authorization header: %s", i, auth)
|
||||
t.Logf("This is current behavior - credentials are sent to all repositories")
|
||||
t.Logf("Future improvement: implement host-specific credential scoping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testCredentialEncoding ensures credentials are properly encoded
|
||||
func testCredentialEncoding(t *testing.T) {
|
||||
// Test with special characters that need proper encoding
|
||||
testCases := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
}{
|
||||
{"Normal credentials", "user", "pass"},
|
||||
{"Username with special chars", "user@domain.com", "password"},
|
||||
{"Password with special chars", "user", "p@ssw0rd!#$%"},
|
||||
{"Both with special chars", "user@domain", "p@ss:w0rd"},
|
||||
{"Unicode characters", "użytkownik", "hasło123"},
|
||||
// Note: Empty password case is intentionally excluded as no auth header should be sent
|
||||
{"Colon in username", "user:name", "password"}, // This should work
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var receivedAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
|
||||
if receivedAuth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify proper Basic Auth format
|
||||
if !strings.HasPrefix(receivedAuth, "Basic ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Invalid auth format"))
|
||||
return
|
||||
}
|
||||
|
||||
// Decode and verify
|
||||
encoded := receivedAuth[6:]
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Invalid base64 encoding"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify format is username:password
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Invalid credential format"))
|
||||
return
|
||||
}
|
||||
|
||||
if parts[0] != tc.username || parts[1] != tc.password {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
// Success response
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries:
|
||||
test-chart:
|
||||
- name: test-chart
|
||||
version: 0.1.0
|
||||
urls: [test-chart-0.1.0.tgz]
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else {
|
||||
w.Write([]byte("fake-chart"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, srv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: tc.username,
|
||||
Password: tc.password,
|
||||
}
|
||||
|
||||
err := m.Update()
|
||||
|
||||
// Verify proper encoding was received
|
||||
if receivedAuth == "" {
|
||||
t.Errorf("No Authorization header received")
|
||||
} else if !strings.HasPrefix(receivedAuth, "Basic ") {
|
||||
t.Errorf("Invalid Authorization header format: %s", receivedAuth)
|
||||
} else {
|
||||
// Decode and verify
|
||||
encoded := receivedAuth[6:]
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to decode base64: %v", err)
|
||||
} else {
|
||||
expected := fmt.Sprintf("%s:%s", tc.username, tc.password)
|
||||
if string(decoded) != expected {
|
||||
t.Errorf("Credential mismatch. Expected: %s, Got: %s", expected, string(decoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For problematic cases, we should handle gracefully
|
||||
if strings.Contains(tc.username, ":") && tc.username != "user:name" {
|
||||
t.Logf("Note: Username contains colon, may cause parsing issues in some systems")
|
||||
}
|
||||
|
||||
_ = err // Ignore other errors for this security test
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testMaliciousCredentialHandling tests handling of potentially malicious credentials
|
||||
func testMaliciousCredentialHandling(t *testing.T) {
|
||||
maliciousInputs := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{"Extremely long username", strings.Repeat("a", 10000), "pass", false},
|
||||
{"Extremely long password", "user", strings.Repeat("a", 10000), false},
|
||||
{"NULL bytes in username", "user\x00malicious", "pass", false},
|
||||
{"NULL bytes in password", "user", "pass\x00malicious", false},
|
||||
{"Newlines in username", "user\nmalicious", "pass", false},
|
||||
{"Newlines in password", "user", "pass\nmalicious", false},
|
||||
{"Control characters", "user\r\t", "pass\r\t", false},
|
||||
}
|
||||
|
||||
for _, tc := range maliciousInputs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
|
||||
// Log received auth for security analysis
|
||||
t.Logf("Received auth header: %q", auth)
|
||||
|
||||
if auth != "" && strings.HasPrefix(auth, "Basic ") {
|
||||
decoded, err := base64.StdEncoding.DecodeString(auth[6:])
|
||||
if err != nil {
|
||||
t.Logf("Failed to decode auth: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
t.Logf("Decoded credentials: %q", string(decoded))
|
||||
}
|
||||
|
||||
// Always respond with success to test credential handling
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries: {}
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else {
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, srv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: tc.username,
|
||||
Password: tc.password,
|
||||
}
|
||||
|
||||
err := m.Update()
|
||||
|
||||
// Ensure malicious input doesn't crash the system
|
||||
if tc.expectError && err == nil {
|
||||
t.Errorf("Expected error for malicious input, but got none")
|
||||
}
|
||||
|
||||
// Ensure no panic occurred and system is stable
|
||||
output := out.String()
|
||||
t.Logf("Output length: %d characters", len(output))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testCredentialMemoryHandling verifies credentials are handled properly in memory
|
||||
func testCredentialMemoryHandling(t *testing.T) {
|
||||
username, password := "testuser", "testpass"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simple success response
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries: {}
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else {
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, srv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
err := m.Update()
|
||||
if err != nil {
|
||||
t.Logf("Update completed with result: %v", err)
|
||||
}
|
||||
|
||||
// Verify credentials are still accessible in Manager (expected behavior)
|
||||
if m.Username != username {
|
||||
t.Errorf("Username was modified: expected %s, got %s", username, m.Username)
|
||||
}
|
||||
if m.Password != password {
|
||||
t.Errorf("Password was modified: expected %s, got %s", password, m.Password)
|
||||
}
|
||||
|
||||
// This test documents current behavior - credentials remain in Manager
|
||||
// Future enhancement could implement credential clearing after use
|
||||
t.Logf("Note: Credentials remain in Manager struct after use")
|
||||
t.Logf("Future enhancement: consider clearing sensitive data after use")
|
||||
}
|
||||
|
||||
// Helper functions for test setup
|
||||
func setupTestChart(t *testing.T, tempDir, repoURL string) string {
|
||||
chartDir := filepath.Join(tempDir, "test-chart")
|
||||
err := os.MkdirAll(filepath.Join(chartDir, "charts"), 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chartYAML := fmt.Sprintf(`apiVersion: v2
|
||||
name: test-chart
|
||||
description: Test chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: "%s"`, repoURL)
|
||||
|
||||
err = os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYAML), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return chartDir
|
||||
}
|
||||
|
||||
func setupCacheDirectories(t *testing.T, tempDir string) (string, string, string) {
|
||||
repoCache := filepath.Join(tempDir, "cache")
|
||||
repoConfig := filepath.Join(tempDir, "repositories.yaml")
|
||||
contentCache := filepath.Join(tempDir, "content")
|
||||
|
||||
for _, dir := range []string{repoCache, contentCache} {
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err := os.WriteFile(repoConfig, []byte("apiVersion: v1\nrepositories: []\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return repoCache, repoConfig, contentCache
|
||||
}
|
||||
|
||||
// TestSecurityRegressionPrevention ensures our fix doesn't reintroduce known security issues
|
||||
func TestSecurityRegressionPrevention(t *testing.T) {
|
||||
t.Run("No credential injection in URLs", func(t *testing.T) {
|
||||
// Ensure credentials don't get injected into URLs themselves
|
||||
testNoCredentialInjectionInURLs(t)
|
||||
})
|
||||
|
||||
t.Run("Timeout handling with auth", func(t *testing.T) {
|
||||
// Ensure auth doesn't interfere with proper timeout handling
|
||||
testTimeoutHandlingWithAuth(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testNoCredentialInjectionInURLs(t *testing.T) {
|
||||
username, password := "testuser", "testpass"
|
||||
|
||||
var requestURLs []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestURLs = append(requestURLs, r.URL.String())
|
||||
|
||||
// Check if credentials somehow ended up in URL
|
||||
fullURL := r.URL.String()
|
||||
if strings.Contains(fullURL, username) || strings.Contains(fullURL, password) {
|
||||
t.Errorf("SECURITY ISSUE: Credentials found in URL: %s", fullURL)
|
||||
}
|
||||
|
||||
// Simple response
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries: {}
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else {
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, srv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
_ = m.Update()
|
||||
|
||||
// Verify no credentials in any requested URLs
|
||||
for _, reqURL := range requestURLs {
|
||||
if strings.Contains(reqURL, username) || strings.Contains(reqURL, password) {
|
||||
t.Errorf("SECURITY VULNERABILITY: Credentials found in request URL: %s", reqURL)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Verified %d URLs contain no embedded credentials", len(requestURLs))
|
||||
}
|
||||
|
||||
func testTimeoutHandlingWithAuth(t *testing.T) {
|
||||
username, password := "testuser", "testpass"
|
||||
|
||||
// Create a server that delays response
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond) // Small delay
|
||||
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries: {}
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else {
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartDir := setupTestChart(t, tempDir, srv.URL)
|
||||
repoCache, repoConfig, contentCache := setupCacheDirectories(t, tempDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
m := &Manager{
|
||||
Out: &out,
|
||||
ChartPath: chartDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryConfig: repoConfig,
|
||||
RepositoryCache: repoCache,
|
||||
ContentCache: contentCache,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
// This should complete normally despite the delay
|
||||
err := m.Update()
|
||||
|
||||
// Ensure auth doesn't interfere with normal error handling
|
||||
output := out.String()
|
||||
if strings.Contains(output, "timeout") {
|
||||
t.Logf("Timeout handling appears normal with auth enabled")
|
||||
}
|
||||
|
||||
_ = err // We're testing that it doesn't hang or panic
|
||||
}
|
||||
|
||||
// TestRegressionPreFixBehavior ensures that the behavior before the fix continues to work
|
||||
// This test guarantees backward compatibility and that existing functionality isn't broken
|
||||
func TestRegressionPreFixBehavior(t *testing.T) {
|
||||
t.Run("Manager without credentials works as before", func(t *testing.T) {
|
||||
testManagerWithoutCredentials(t)
|
||||
})
|
||||
|
||||
t.Run("Empty credentials behave as no credentials", func(t *testing.T) {
|
||||
testEmptyCredentialsBehavior(t)
|
||||
})
|
||||
|
||||
t.Run("Public repository access unchanged", func(t *testing.T) {
|
||||
testPublicRepositoryAccess(t)
|
||||
})
|
||||
|
||||
t.Run("Error handling unchanged", func(t *testing.T) {
|
||||
testErrorHandlingUnchanged(t)
|
||||
})
|
||||
}
|
||||
|
||||
// testManagerWithoutCredentials ensures that a Manager without Username/Password
|
||||
// fields set continues to work exactly as it did before the fix
|
||||
func testManagerWithoutCredentials(t *testing.T) {
|
||||
// Create a test server that serves a simple index.yaml
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should NOT receive any Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
t.Errorf("Expected no Authorization header, but got: %s", authHeader)
|
||||
}
|
||||
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries:
|
||||
test-chart:
|
||||
- name: test-chart
|
||||
version: 0.1.0
|
||||
urls: [test-chart-0.1.0.tgz]
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else if strings.HasSuffix(r.URL.Path, ".tgz") {
|
||||
w.Header().Set("Content-Type", "application/x-gzip")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("fake-chart-content"))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Create temporary directory for test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create Chart.yaml
|
||||
chartYaml := `apiVersion: v2
|
||||
name: test-chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: ` + srv.URL
|
||||
|
||||
chartPath := filepath.Join(tempDir, "Chart.yaml")
|
||||
if err := os.WriteFile(chartPath, []byte(chartYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create Chart.yaml: %v", err)
|
||||
}
|
||||
|
||||
// Create Manager WITHOUT Username/Password (as it was before the fix)
|
||||
out := &bytes.Buffer{}
|
||||
man := &Manager{
|
||||
Out: out,
|
||||
ChartPath: tempDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryCache: tempDir, // Add cache to avoid cache errors
|
||||
// Note: Username and Password are intentionally NOT set
|
||||
// This mimics the behavior before the fix
|
||||
}
|
||||
|
||||
// Test that Update works as before (should attempt without credentials)
|
||||
err := man.Update()
|
||||
|
||||
// The exact error doesn't matter - we're testing that:
|
||||
// 1. No authorization header is sent
|
||||
// 2. The code doesn't panic or break
|
||||
// 3. The error handling is the same as before
|
||||
if err != nil {
|
||||
t.Logf("Update failed as expected without credentials: %v", err)
|
||||
} else {
|
||||
t.Log("Update succeeded without credentials (public repo behavior)")
|
||||
}
|
||||
|
||||
// Verify no auth-related output in logs
|
||||
output := out.String()
|
||||
if strings.Contains(strings.ToLower(output), "authorization") ||
|
||||
strings.Contains(strings.ToLower(output), "username") ||
|
||||
strings.Contains(strings.ToLower(output), "password") {
|
||||
t.Errorf("Unexpected auth-related output when no credentials provided: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// testEmptyCredentialsBehavior ensures that empty Username/Password strings
|
||||
// behave exactly like no credentials (backward compatibility)
|
||||
func testEmptyCredentialsBehavior(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should NOT receive any Authorization header when credentials are empty
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
t.Errorf("Expected no Authorization header with empty credentials, but got: %s", authHeader)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries: {}
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartYaml := `apiVersion: v2
|
||||
name: test-chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: ` + srv.URL
|
||||
|
||||
chartPath := filepath.Join(tempDir, "Chart.yaml")
|
||||
if err := os.WriteFile(chartPath, []byte(chartYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create Chart.yaml: %v", err)
|
||||
}
|
||||
|
||||
// Create Manager with empty credentials (should behave like no credentials)
|
||||
out := &bytes.Buffer{}
|
||||
man := &Manager{
|
||||
Out: out,
|
||||
ChartPath: tempDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryCache: tempDir, // Add cache
|
||||
Username: "", // Empty string
|
||||
Password: "", // Empty string
|
||||
}
|
||||
|
||||
err := man.Update()
|
||||
|
||||
// We don't care about the specific error, just that no auth header was sent
|
||||
_ = err
|
||||
|
||||
t.Log("Empty credentials behaved as no credentials (backward compatibility maintained)")
|
||||
}
|
||||
|
||||
// testPublicRepositoryAccess ensures that access to public repositories
|
||||
// continues to work unchanged
|
||||
func testPublicRepositoryAccess(t *testing.T) {
|
||||
// Simulate a public repository that doesn't require authentication
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Public repos should work regardless of whether auth headers are present or not
|
||||
// This tests that we don't break public repo access
|
||||
|
||||
if r.URL.Path == "/index.yaml" {
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`apiVersion: v1
|
||||
entries:
|
||||
public-chart:
|
||||
- name: public-chart
|
||||
version: 1.0.0
|
||||
urls: [public-chart-1.0.0.tgz]
|
||||
generated: "2025-09-28T20:00:00Z"`))
|
||||
} else if strings.HasSuffix(r.URL.Path, ".tgz") {
|
||||
w.Header().Set("Content-Type", "application/x-gzip")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("fake-chart-content"))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
chartYaml := `apiVersion: v2
|
||||
name: test-chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: public-chart
|
||||
version: "1.0.0"
|
||||
repository: ` + srv.URL
|
||||
|
||||
chartPath := filepath.Join(tempDir, "Chart.yaml")
|
||||
if err := os.WriteFile(chartPath, []byte(chartYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create Chart.yaml: %v", err)
|
||||
}
|
||||
|
||||
// Test both scenarios: with and without credentials for public repo
|
||||
scenarios := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
}{
|
||||
{"Public repo without credentials", "", ""},
|
||||
{"Public repo with credentials (should still work)", "user", "pass"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
man := &Manager{
|
||||
Out: out,
|
||||
ChartPath: tempDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryCache: tempDir, // Add cache
|
||||
Username: scenario.username,
|
||||
Password: scenario.password,
|
||||
}
|
||||
|
||||
err := man.Update()
|
||||
if err != nil {
|
||||
// For this test we expect some error since we're not providing a real chart
|
||||
// but the important thing is that it doesn't fail due to auth issues
|
||||
if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("Public repository access failed with auth error: %v", err)
|
||||
} else {
|
||||
t.Logf("Public repository access failed with expected non-auth error: %v", err)
|
||||
}
|
||||
} else {
|
||||
t.Log("Public repository access succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testErrorHandlingUnchanged ensures that error handling behavior
|
||||
// remains the same as before the fix
|
||||
func testErrorHandlingUnchanged(t *testing.T) {
|
||||
// Test various error scenarios to ensure they're handled the same way
|
||||
|
||||
t.Run("Invalid repository URL", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
chartYaml := `apiVersion: v2
|
||||
name: test-chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: "invalid-url"`
|
||||
|
||||
chartPath := filepath.Join(tempDir, "Chart.yaml")
|
||||
if err := os.WriteFile(chartPath, []byte(chartYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create Chart.yaml: %v", err)
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
man := &Manager{
|
||||
Out: out,
|
||||
ChartPath: tempDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryCache: tempDir, // Add cache
|
||||
// Test both with and without credentials
|
||||
}
|
||||
|
||||
err := man.Update()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL, but got none")
|
||||
}
|
||||
|
||||
// The error should be about the URL, not about authentication
|
||||
if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("Got auth error for invalid URL, expected URL error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-existent repository", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
chartYaml := `apiVersion: v2
|
||||
name: test-chart
|
||||
version: 0.1.0
|
||||
dependencies:
|
||||
- name: test-chart
|
||||
version: "0.1.0"
|
||||
repository: "http://localhost:99999/non-existent"`
|
||||
|
||||
chartPath := filepath.Join(tempDir, "Chart.yaml")
|
||||
if err := os.WriteFile(chartPath, []byte(chartYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create Chart.yaml: %v", err)
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
man := &Manager{
|
||||
Out: out,
|
||||
ChartPath: tempDir,
|
||||
Getters: getter.All(&cli.EnvSettings{}),
|
||||
RepositoryCache: tempDir, // Add cache
|
||||
}
|
||||
|
||||
err := man.Update()
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent repository, but got none")
|
||||
}
|
||||
|
||||
// Should get connection error, not auth error
|
||||
if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("Got auth error for connection issue, expected connection error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Log("Error handling behavior verified to be unchanged")
|
||||
}
|
||||
Loading…
Reference in new issue