package integration import ( "crypto/tls" "encoding/json" "fmt" "io" "net/http" "strings" "testing" "time" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/tsic" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protojson" ) // TestAPIAuthenticationBypass tests that the API authentication middleware // properly blocks unauthorized requests and does not leak sensitive data. // This test reproduces the security issue described in: // - https://github.com/juanfont/headscale/issues/2809 // - https://github.com/juanfont/headscale/pull/2810 // // The bug: When authentication fails, the middleware writes "Unauthorized" // but doesn't return early, allowing the handler to execute and append // sensitive data to the response. func TestAPIAuthenticationBypass(t *testing.T) { IntegrationSkip(t) spec := ScenarioSpec{ Users: []string{"user1", "user2", "user3"}, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("apiauthbypass")) require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) // Create an API key using the CLI var validAPIKey string assert.EventuallyWithT(t, func(ct *assert.CollectT) { apiKeyOutput, err := headscale.Execute( []string{ "headscale", "apikeys", "create", "--expiration", "24h", }, ) assert.NoError(ct, err) assert.NotEmpty(ct, apiKeyOutput) validAPIKey = strings.TrimSpace(apiKeyOutput) }, 20*time.Second, 1*time.Second) // Get the API endpoint endpoint := headscale.GetEndpoint() apiURL := fmt.Sprintf("%s/api/v1/user", endpoint) // Create HTTP client client := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec }, } t.Run("HTTP_NoAuthHeader", func(t *testing.T) { // Test 1: Request without any Authorization header // Expected: Should return 401 with ONLY "Unauthorized" text, no user data req, err := http.NewRequest("GET", apiURL, nil) require.NoError(t, err) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) // Should return 401 Unauthorized assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Expected 401 status code for request without auth header") bodyStr := string(body) // Should contain "Unauthorized" message assert.Contains(t, bodyStr, "Unauthorized", "Response should contain 'Unauthorized' message") // Should NOT contain user data after "Unauthorized" // This is the security bypass - if users array is present, auth was bypassed var jsonCheck map[string]interface{} jsonErr := json.Unmarshal(body, &jsonCheck) // If we can unmarshal JSON and it contains "users", that's the bypass if jsonErr == nil { assert.NotContains(t, jsonCheck, "users", "SECURITY ISSUE: Response should NOT contain 'users' data when unauthorized") assert.NotContains(t, jsonCheck, "user", "SECURITY ISSUE: Response should NOT contain 'user' data when unauthorized") } // Additional check: response should not contain "user1", "user2", "user3" assert.NotContains(t, bodyStr, "user1", "SECURITY ISSUE: Response should NOT leak user 'user1' data") assert.NotContains(t, bodyStr, "user2", "SECURITY ISSUE: Response should NOT leak user 'user2' data") assert.NotContains(t, bodyStr, "user3", "SECURITY ISSUE: Response should NOT leak user 'user3' data") // Response should be minimal, just "Unauthorized" // Allow some variation in response format but body should be small assert.Less(t, len(bodyStr), 100, "SECURITY ISSUE: Unauthorized response body should be minimal, got: %s", bodyStr) }) t.Run("HTTP_InvalidAuthHeader", func(t *testing.T) { // Test 2: Request with invalid Authorization header (missing "Bearer " prefix) // Expected: Should return 401 with ONLY "Unauthorized" text, no user data req, err := http.NewRequest("GET", apiURL, nil) require.NoError(t, err) req.Header.Set("Authorization", "InvalidToken") resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Expected 401 status code for invalid auth header format") bodyStr := string(body) assert.Contains(t, bodyStr, "Unauthorized") // Should not leak user data assert.NotContains(t, bodyStr, "user1", "SECURITY ISSUE: Response should NOT leak user data") assert.NotContains(t, bodyStr, "user2", "SECURITY ISSUE: Response should NOT leak user data") assert.NotContains(t, bodyStr, "user3", "SECURITY ISSUE: Response should NOT leak user data") assert.Less(t, len(bodyStr), 100, "SECURITY ISSUE: Unauthorized response should be minimal") }) t.Run("HTTP_InvalidBearerToken", func(t *testing.T) { // Test 3: Request with Bearer prefix but invalid token // Expected: Should return 401 with ONLY "Unauthorized" text, no user data // Note: Both malformed and properly formatted invalid tokens should return 401 req, err := http.NewRequest("GET", apiURL, nil) require.NoError(t, err) req.Header.Set("Authorization", "Bearer invalid-token-12345") resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Expected 401 status code for invalid bearer token") bodyStr := string(body) assert.Contains(t, bodyStr, "Unauthorized") // Should not leak user data assert.NotContains(t, bodyStr, "user1", "SECURITY ISSUE: Response should NOT leak user data") assert.NotContains(t, bodyStr, "user2", "SECURITY ISSUE: Response should NOT leak user data") assert.NotContains(t, bodyStr, "user3", "SECURITY ISSUE: Response should NOT leak user data") assert.Less(t, len(bodyStr), 100, "SECURITY ISSUE: Unauthorized response should be minimal") }) t.Run("HTTP_ValidAPIKey", func(t *testing.T) { // Test 4: Request with valid API key // Expected: Should return 200 with user data (this is the authorized case) req, err := http.NewRequest("GET", apiURL, nil) require.NoError(t, err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", validAPIKey)) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) // Should succeed with valid auth assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 status code with valid API key") // Should be able to parse as protobuf JSON var response v1.ListUsersResponse err = protojson.Unmarshal(body, &response) assert.NoError(t, err, "Response should be valid protobuf JSON with valid API key") // Should contain our test users users := response.GetUsers() assert.Len(t, users, 3, "Should have 3 users") userNames := make([]string, len(users)) for i, u := range users { userNames[i] = u.GetName() } assert.Contains(t, userNames, "user1") assert.Contains(t, userNames, "user2") assert.Contains(t, userNames, "user3") }) } // TestAPIAuthenticationBypassCurl tests the same security issue using curl // from inside a container, which is closer to how the issue was discovered. func TestAPIAuthenticationBypassCurl(t *testing.T) { IntegrationSkip(t) spec := ScenarioSpec{ Users: []string{"testuser1", "testuser2"}, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("apiauthcurl")) require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) // Create a valid API key apiKeyOutput, err := headscale.Execute( []string{ "headscale", "apikeys", "create", "--expiration", "24h", }, ) require.NoError(t, err) validAPIKey := strings.TrimSpace(apiKeyOutput) endpoint := headscale.GetEndpoint() apiURL := fmt.Sprintf("%s/api/v1/user", endpoint) t.Run("Curl_NoAuth", func(t *testing.T) { // Execute curl from inside the headscale container without auth curlOutput, err := headscale.Execute( []string{ "curl", "-s", "-w", "\nHTTP_CODE:%{http_code}", apiURL, }, ) require.NoError(t, err) // Parse the output lines := strings.Split(curlOutput, "\n") var httpCode string var responseBody string for _, line := range lines { if strings.HasPrefix(line, "HTTP_CODE:") { httpCode = strings.TrimPrefix(line, "HTTP_CODE:") } else { responseBody += line } } // Should return 401 assert.Equal(t, "401", httpCode, "Curl without auth should return 401") // Should contain Unauthorized assert.Contains(t, responseBody, "Unauthorized", "Response should contain 'Unauthorized'") // Should NOT leak user data assert.NotContains(t, responseBody, "testuser1", "SECURITY ISSUE: Should not leak user data") assert.NotContains(t, responseBody, "testuser2", "SECURITY ISSUE: Should not leak user data") // Response should be small (just "Unauthorized") assert.Less(t, len(responseBody), 100, "SECURITY ISSUE: Unauthorized response should be minimal, got: %s", responseBody) }) t.Run("Curl_InvalidAuth", func(t *testing.T) { // Execute curl with invalid auth header curlOutput, err := headscale.Execute( []string{ "curl", "-s", "-H", "Authorization: InvalidToken", "-w", "\nHTTP_CODE:%{http_code}", apiURL, }, ) require.NoError(t, err) lines := strings.Split(curlOutput, "\n") var httpCode string var responseBody string for _, line := range lines { if strings.HasPrefix(line, "HTTP_CODE:") { httpCode = strings.TrimPrefix(line, "HTTP_CODE:") } else { responseBody += line } } assert.Equal(t, "401", httpCode) assert.Contains(t, responseBody, "Unauthorized") assert.NotContains(t, responseBody, "testuser1", "SECURITY ISSUE: Should not leak user data") assert.NotContains(t, responseBody, "testuser2", "SECURITY ISSUE: Should not leak user data") }) t.Run("Curl_ValidAuth", func(t *testing.T) { // Execute curl with valid API key curlOutput, err := headscale.Execute( []string{ "curl", "-s", "-H", fmt.Sprintf("Authorization: Bearer %s", validAPIKey), "-w", "\nHTTP_CODE:%{http_code}", apiURL, }, ) require.NoError(t, err) lines := strings.Split(curlOutput, "\n") var httpCode string var responseBody string for _, line := range lines { if strings.HasPrefix(line, "HTTP_CODE:") { httpCode = strings.TrimPrefix(line, "HTTP_CODE:") } else { responseBody += line } } // Should succeed assert.Equal(t, "200", httpCode, "Curl with valid API key should return 200") // Should contain user data var response v1.ListUsersResponse err = protojson.Unmarshal([]byte(responseBody), &response) assert.NoError(t, err, "Response should be valid protobuf JSON") users := response.GetUsers() assert.Len(t, users, 2, "Should have 2 users") }) } // TestGRPCAuthenticationBypass tests that the gRPC authentication interceptor // properly blocks unauthorized requests. // This test verifies that the gRPC API does not have the same bypass issue // as the HTTP API middleware. func TestGRPCAuthenticationBypass(t *testing.T) { IntegrationSkip(t) spec := ScenarioSpec{ Users: []string{"grpcuser1", "grpcuser2"}, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) // We need TLS for remote gRPC connections err = scenario.CreateHeadscaleEnv( []tsic.Option{}, hsic.WithTestName("grpcauthtest"), hsic.WithTLS(), hsic.WithConfigEnv(map[string]string{ // Enable gRPC on the standard port "HEADSCALE_GRPC_LISTEN_ADDR": "0.0.0.0:50443", }), ) require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) // Create a valid API key apiKeyOutput, err := headscale.Execute( []string{ "headscale", "apikeys", "create", "--expiration", "24h", }, ) require.NoError(t, err) validAPIKey := strings.TrimSpace(apiKeyOutput) // Get the gRPC endpoint // For gRPC, we need to use the hostname and port 50443 grpcAddress := fmt.Sprintf("%s:50443", headscale.GetHostname()) t.Run("gRPC_NoAPIKey", func(t *testing.T) { // Test 1: Try to use CLI without API key (should fail) // When HEADSCALE_CLI_ADDRESS is set but HEADSCALE_CLI_API_KEY is not set, // the CLI should fail immediately _, err := headscale.Execute( []string{ "sh", "-c", fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", grpcAddress), }, ) // Should fail - CLI exits when API key is missing assert.Error(t, err, "gRPC connection without API key should fail") }) t.Run("gRPC_InvalidAPIKey", func(t *testing.T) { // Test 2: Try to use CLI with invalid API key (should fail with auth error) output, err := headscale.Execute( []string{ "sh", "-c", fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=invalid-key-12345 HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", grpcAddress), }, ) // Should fail with authentication error assert.Error(t, err, "gRPC connection with invalid API key should fail") // Should contain authentication error message outputStr := strings.ToLower(output) assert.True(t, strings.Contains(outputStr, "unauthenticated") || strings.Contains(outputStr, "invalid token") || strings.Contains(outputStr, "failed to validate token") || strings.Contains(outputStr, "authentication"), "Error should indicate authentication failure, got: %s", output) // Should NOT leak user data assert.NotContains(t, output, "grpcuser1", "SECURITY ISSUE: gRPC should not leak user data with invalid auth") assert.NotContains(t, output, "grpcuser2", "SECURITY ISSUE: gRPC should not leak user data with invalid auth") }) t.Run("gRPC_ValidAPIKey", func(t *testing.T) { // Test 3: Use CLI with valid API key (should succeed) output, err := headscale.Execute( []string{ "sh", "-c", fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json", grpcAddress, validAPIKey), }, ) // Should succeed assert.NoError(t, err, "gRPC connection with valid API key should succeed, output: %s", output) // CLI outputs the users array directly, not wrapped in ListUsersResponse // Parse as JSON array (CLI uses json.Marshal, not protojson) var users []*v1.User err = json.Unmarshal([]byte(output), &users) assert.NoError(t, err, "Response should be valid JSON array") assert.Len(t, users, 2, "Should have 2 users") userNames := make([]string, len(users)) for i, u := range users { userNames[i] = u.GetName() } assert.Contains(t, userNames, "grpcuser1") assert.Contains(t, userNames, "grpcuser2") }) } // TestCLIWithConfigAuthenticationBypass tests that the headscale CLI // with --config flag does not have authentication bypass issues when // connecting to a remote server. // Note: When using --config with local unix socket, no auth is needed. // This test focuses on remote gRPC connections which require API keys. func TestCLIWithConfigAuthenticationBypass(t *testing.T) { IntegrationSkip(t) spec := ScenarioSpec{ Users: []string{"cliuser1", "cliuser2"}, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) err = scenario.CreateHeadscaleEnv( []tsic.Option{}, hsic.WithTestName("cliconfigauth"), hsic.WithTLS(), hsic.WithConfigEnv(map[string]string{ "HEADSCALE_GRPC_LISTEN_ADDR": "0.0.0.0:50443", }), ) require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) // Create a valid API key apiKeyOutput, err := headscale.Execute( []string{ "headscale", "apikeys", "create", "--expiration", "24h", }, ) require.NoError(t, err) validAPIKey := strings.TrimSpace(apiKeyOutput) grpcAddress := fmt.Sprintf("%s:50443", headscale.GetHostname()) // Create a config file for testing configWithoutKey := fmt.Sprintf(` cli: address: %s timeout: 5s insecure: true `, grpcAddress) configWithInvalidKey := fmt.Sprintf(` cli: address: %s api_key: invalid-key-12345 timeout: 5s insecure: true `, grpcAddress) configWithValidKey := fmt.Sprintf(` cli: address: %s api_key: %s timeout: 5s insecure: true `, grpcAddress, validAPIKey) t.Run("CLI_Config_NoAPIKey", func(t *testing.T) { // Create config file without API key err := headscale.WriteFile("/tmp/config_no_key.yaml", []byte(configWithoutKey)) require.NoError(t, err) // Try to use CLI with config that has no API key _, err = headscale.Execute( []string{ "headscale", "--config", "/tmp/config_no_key.yaml", "users", "list", "--output", "json", }, ) // Should fail assert.Error(t, err, "CLI with config missing API key should fail") }) t.Run("CLI_Config_InvalidAPIKey", func(t *testing.T) { // Create config file with invalid API key err := headscale.WriteFile("/tmp/config_invalid_key.yaml", []byte(configWithInvalidKey)) require.NoError(t, err) // Try to use CLI with invalid API key output, err := headscale.Execute( []string{ "sh", "-c", "headscale --config /tmp/config_invalid_key.yaml users list --output json 2>&1", }, ) // Should fail assert.Error(t, err, "CLI with invalid API key should fail") // Should indicate authentication failure outputStr := strings.ToLower(output) assert.True(t, strings.Contains(outputStr, "unauthenticated") || strings.Contains(outputStr, "invalid token") || strings.Contains(outputStr, "failed to validate token") || strings.Contains(outputStr, "authentication"), "Error should indicate authentication failure, got: %s", output) // Should NOT leak user data assert.NotContains(t, output, "cliuser1", "SECURITY ISSUE: CLI should not leak user data with invalid auth") assert.NotContains(t, output, "cliuser2", "SECURITY ISSUE: CLI should not leak user data with invalid auth") }) t.Run("CLI_Config_ValidAPIKey", func(t *testing.T) { // Create config file with valid API key err := headscale.WriteFile("/tmp/config_valid_key.yaml", []byte(configWithValidKey)) require.NoError(t, err) // Use CLI with valid API key output, err := headscale.Execute( []string{ "headscale", "--config", "/tmp/config_valid_key.yaml", "users", "list", "--output", "json", }, ) // Should succeed assert.NoError(t, err, "CLI with valid API key should succeed") // CLI outputs the users array directly, not wrapped in ListUsersResponse // Parse as JSON array (CLI uses json.Marshal, not protojson) var users []*v1.User err = json.Unmarshal([]byte(output), &users) assert.NoError(t, err, "Response should be valid JSON array") assert.Len(t, users, 2, "Should have 2 users") userNames := make([]string, len(users)) for i, u := range users { userNames[i] = u.GetName() } assert.Contains(t, userNames, "cliuser1") assert.Contains(t, userNames, "cliuser2") }) }