VYPR
High severityNVD Advisory· Published Aug 30, 2021· Updated Aug 4, 2024

Cross-Site Request Forgery (CSRF) can run untrusted code on Rundeck server

CVE-2021-39133

Description

Rundeck is an open source automation service with a web console, command line tools and a WebAPI. Prior to version 3.3.14 and version 3.4.3, a user with admin access to the system resource type is potentially vulnerable to a CSRF attack that could cause the server to run untrusted code on all Rundeck editions. Patches are available in Rundeck versions 3.4.3 and 3.3.14.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.rundeck:rundeck-coreMaven
>= 3.4.0, < 3.4.33.4.3
org.rundeck:rundeck-coreMaven
< 3.3.143.3.14

Affected products

1

Patches

1
67c4eedeaf95

Merge pull request from GHSA-3jmw-c69h-426c

https://github.com/rundeck/rundeckGreg SchuelerAug 13, 2021via ghsa
4 files changed · +241 30
  • rundeckapp/grails-app/controllers/rundeck/controllers/PluginController.groovy+34 5 modified
    @@ -13,24 +13,31 @@ import com.dtolabs.rundeck.core.plugins.configuration.PluginAdapterUtility
     import grails.converters.JSON
     import groovy.transform.CompileStatic
     import org.springframework.web.multipart.MultipartFile
    +import org.springframework.web.multipart.MultipartHttpServletRequest
     import org.springframework.web.servlet.support.RequestContextUtils
    +import rundeck.services.ApiService
     import rundeck.services.FrameworkService
     import rundeck.services.PluginApiService
     import rundeck.services.PluginService
     import rundeck.services.UiPluginService
    -import rundeck.services.feature.FeatureService
     
    +import javax.servlet.http.HttpServletResponse
     import java.text.SimpleDateFormat
     
     import static org.springframework.http.HttpStatus.NOT_FOUND
     
     class PluginController extends ControllerBase {
         private static final String RELATIVE_PLUGIN_UPLOAD_DIR = "var/tmp/pluginUpload"
         private static final SimpleDateFormat PLUGIN_DATE_FMT = new SimpleDateFormat("EEE MMM dd hh:mm:ss Z yyyy")
    +    static def allowedMethods = [
    +        installPlugin: ['POST'],
    +        uploadPlugin: ['POST']
    +    ]
         UiPluginService uiPluginService
         PluginService pluginService
         PluginApiService pluginApiService
         FrameworkService frameworkService
    +    ApiService apiService
         def featureService
         AppAuthContextProcessor rundeckAuthContextProcessor
         AuthorizedServicesProvider rundeckAuthorizedServicesProvider
    @@ -422,13 +429,31 @@ class PluginController extends ControllerBase {
                 stream.close()
             }
         }
    +    protected boolean requireAjaxFormToken(){
    +        boolean valid = false
    +        withForm {
    +            g.refreshFormTokensHeader()
    +            valid = true
    +        }.invalidToken {
    +        }
    +        if (!valid) {
    +            apiService.renderErrorFormat(response, [
    +                status: HttpServletResponse.SC_BAD_REQUEST,
    +                code: 'request.error.invalidtoken.message',
    +            ])
    +        }
    +        return valid
    +    }
     
         def uploadPlugin() {
     
             if(featureService.featurePresent(Features.PLUGIN_SECURITY)){
                 renderErrorCodeAsJson("plugin.error.unauthorized.upload")
                 return
             }
    +        if(!requireAjaxFormToken()){
    +            return
    +        }
     
             AuthContext authContext = rundeckAuthContextProcessor.getAuthContextForSubject(session.subject)
             boolean authorized = rundeckAuthContextProcessor.authorizeApplicationResourceType(authContext,
    @@ -438,15 +463,16 @@ class PluginController extends ControllerBase {
                 renderErrorCodeAsJson("request.error.unauthorized.title")
                 return
             }
    -        if(!params.pluginFile || params.pluginFile.isEmpty()) {
    +        if(!(request instanceof MultipartHttpServletRequest && request.getFile('pluginFile'))){
                 renderErrorCodeAsJson("plugin.error.missing.upload.file")
                 return
             }
    +        def file = request.getFile('pluginFile')
             ensureUploadLocation()
    -        File tmpFile = new File(frameworkService.getRundeckFramework().baseDir,RELATIVE_PLUGIN_UPLOAD_DIR+"/"+params.pluginFile.originalFilename)
    +        File tmpFile = new File(frameworkService.getRundeckFramework().baseDir,RELATIVE_PLUGIN_UPLOAD_DIR+"/"+file.originalFilename)
             if(tmpFile.exists()) tmpFile.delete()
    -        tmpFile << ((MultipartFile)params.pluginFile).inputStream
    -        def errors = validateAndCopyPlugin(params.pluginFile.originalFilename, tmpFile)
    +        tmpFile << file.inputStream
    +        def errors = validateAndCopyPlugin(file.originalFilename, tmpFile)
             tmpFile.delete()
             def msg = [:]
             if(!errors.isEmpty()) {
    @@ -459,6 +485,9 @@ class PluginController extends ControllerBase {
         }
     
         def installPlugin() {
    +        if(!requireAjaxFormToken()){
    +            return
    +        }
             AuthContext authContext = rundeckAuthContextProcessor.getAuthContextForSubject(session.subject)
             boolean authorized = rundeckAuthContextProcessor.authorizeApplicationResourceType(authContext,
                                                                                    "system",
    
  • rundeckapp/grails-spa/packages/ui/src/pages/repository/components/PluginUploadForm.vue+17 1 modified
    @@ -27,6 +27,7 @@
     <script>
     import { mapState, mapActions } from "vuex";
     import axios from "axios";
    +import {client} from "@rundeck/ui-trellis/lib/modules/rundeckClient"
     export default {
       name: "UploadPluginForm",
       computed: {
    @@ -52,17 +53,22 @@ export default {
             loadingMessage: "Installing",
             loadingSpinner: true
           });
    +      //use axios instead of RundeckClient, to allow multipart form with file upload
           axios({
             method: "post",
             headers: {
               "x-rundeck-ajax": true,
    -          "Content-Type": "multipart/form-data"
    +          "Content-Type": "multipart/form-data",
    +          "X-RUNDECK-TOKEN-KEY": client.token,
    +          "X-RUNDECK-TOKEN-URI": client.uri
             },
             data: formData,
             url: `${window._rundeck.rdBase}plugin/uploadPlugin`,
             withCredentials: true
           }).then(response => {
             this.$store.dispatch("overlay/openOverlay");
    +        client.token = response.headers['x-rundeck-token-key'] || client.token
    +        client.uri = response.headers['x-rundeck-token-uri'] || client.uri
             if (response.data.err) {
               this.$alert({
                 title: "Error Uploading",
    @@ -74,6 +80,16 @@ export default {
                 content: response.data.msg
               });
             }
    +      }).catch(result=>{
    +        this.$store.dispatch("overlay/openOverlay");
    +        let message=result.message
    +        if(result.response && result.response.data && result.response.data.message){
    +          message=result.response.data.message
    +        }
    +        this.$alert({
    +          title: "Error Uploading",
    +          content: message
    +        });
           });
         },
         handleFilesUploads() {
    
  • rundeckapp/grails-spa/packages/ui/src/pages/repository/components/PluginURLUploadForm.vue+29 17 modified
    @@ -18,7 +18,7 @@
       </div>
     </template>
     <script>
    -import axios from "axios";
    +import {client} from "@rundeck/ui-trellis/lib/modules/rundeckClient"
     export default {
       name: "PluginUrlUploadForm",
       data() {
    @@ -32,26 +32,38 @@ export default {
             loadingMessage: "Installing",
             loadingSpinner: true
           });
    -      axios({
    -        method: "post",
    -        headers: {
    -          "x-rundeck-ajax": true
    +      client.sendRequest({
    +        baseUrl: window._rundeck.rdBase,
    +        pathTemplate: `/plugin/installPlugin`,
    +        queryParameters: {
    +          pluginUrl: this.pluginURL
             },
    -        url: `${window._rundeck.rdBase}plugin/installPlugin?pluginUrl=${
    -          this.pluginURL
    -        }`,
    -        withCredentials: true
    +        method: 'POST'
           }).then(response => {
    -        this.$store.dispatch("overlay/openOverlay");
    -        if (response.data.err) {
    +        if (response.status === 200) {
    +          this.$store.dispatch("overlay/openOverlay");
    +          if (response.parsedBody.err) {
    +            this.$alert({
    +              title: "Error Uploading",
    +              content: response.parsedBody.err
    +            });
    +          } else {
    +            this.$alert({
    +              title: "Plugin Installed",
    +              content: response.parsedBody.msg
    +            });
    +          }
    +        }else if (response.status >= 300) {
    +          this.$store.dispatch("overlay/openOverlay");
    +          let message = `Error: ${response.status}`
    +          if (response.parsedBody && response.parsedBody.message) {
    +            message = response.parsedBody.message
    +          }else if (response.parsedBody && response.parsedBody.error) {
    +            message = response.parsedBody.error
    +          }
               this.$alert({
                 title: "Error Uploading",
    -            content: response.data.err
    -          });
    -        } else {
    -          this.$alert({
    -            title: "Plugin Installed",
    -            content: response.data.msg
    +            content: message
               });
             }
           });
    
  • rundeckapp/src/test/groovy/rundeck/controllers/PluginControllerSpec.groovy+161 7 modified
    @@ -18,14 +18,20 @@ import com.dtolabs.rundeck.core.plugins.configuration.Validator
     import com.dtolabs.rundeck.plugins.notification.NotificationPlugin
     import com.dtolabs.rundeck.core.plugins.DescribedPlugin
     import grails.testing.web.controllers.ControllerUnitTest
    +import org.grails.web.servlet.mvc.SynchronizerTokensHolder
     import org.rundeck.app.authorization.AppAuthContextProcessor
    +import rundeck.UtilityTagLib
    +import rundeck.services.ApiService
     import rundeck.services.FrameworkService
     import rundeck.services.PluginApiService
     import rundeck.services.PluginApiServiceSpec
     import rundeck.services.PluginService
     import rundeck.services.UiPluginService
     import rundeck.services.FrameworkService
     import spock.lang.Specification
    +import spock.lang.Unroll
    +
    +import javax.servlet.http.HttpServletResponse
     
     class PluginControllerSpec extends Specification implements ControllerUnitTest<PluginController> {
     
    @@ -40,6 +46,12 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
     "actions.entry[dbd3da9c_1].config.actions.type":"","actions.entry[dbd3da9c_1].config.actions.config.stringvalue":"asdf",
     "actions.entry[dbd3da9c_1].config.actions":"{stringvalue=asdf}"},"report":{}}'''
     
    +    def setup(){
    +        grailsApplication.config.clear()
    +        grailsApplication.config.rundeck.security.useHMacRequestTokens = 'false'
    +        mockTagLib(UtilityTagLib)
    +    }
    +
         void "validate"() {
             given:
                 request.content = json.bytes
    @@ -319,8 +331,10 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
     
             controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
             messageSource.addMessage("plugin.error.missing.upload.file",Locale.ENGLISH,"A plugin file must be specified")
    -
    +        controller.apiService=Mock(ApiService)
             when:
    +        request.method='POST'
    +        setupFormTokens(params)
             controller.uploadPlugin()
     
             then:
    @@ -339,6 +353,7 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
             messageSource.addMessage("plugin.error.unauthorized.upload",Locale.ENGLISH,"Unable to upload plugins")
     
             when:
    +            request.method='POST'
             controller.uploadPlugin()
     
             then:
    @@ -350,10 +365,12 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
             setup:
             controller.frameworkService = Mock(FrameworkService)
     
    -            controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
    +        controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
             messageSource.addMessage("plugin.error.missing.url",Locale.ENGLISH,"The plugin URL is required")
    -
    +        controller.apiService=Mock(ApiService)
             when:
    +        request.method='POST'
    +        setupFormTokens(params)
             controller.installPlugin()
     
             then:
    @@ -375,9 +392,10 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
             }
             fwksvc.getRundeckFramework() >> fwk
             controller.frameworkService = fwksvc
    -
    -
    +        controller.apiService=Mock(ApiService)
             when:
    +        request.method='POST'
    +        setupFormTokens(params)
             !uploaded.exists()
             def pluginInputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(PLUGIN_FILE)
             request.addFile(new GrailsMockMultipartFile("pluginFile",PLUGIN_FILE,"application/octet-stream",pluginInputStream))
    @@ -393,6 +411,65 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
             cleanup:
             uploaded.delete()
         }
    +    @Unroll
    +    void "upload plugin requires POST method"() {
    +        setup:
    +        def fwksvc = Mock(FrameworkService)
    +
    +        controller.featureService = Mock(FeatureService)
    +            controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
    +        def fwk = Mock(Framework) {
    +            getBaseDir() >> uploadTestBaseDir
    +            getLibextDir() >> uploadTestTargetDir
    +        }
    +        fwksvc.getRundeckFramework() >> fwk
    +        controller.frameworkService = fwksvc
    +        controller.apiService=Mock(ApiService)
    +        when: "request made without POST method"
    +        request.method=method
    +        setupFormTokens(params)
    +        controller.uploadPlugin()
    +
    +        then:
    +        response.status==405
    +        where:
    +            method << ['get', 'put', 'delete', 'head']
    +    }
    +    void "upload plugin requires synch token"() {
    +        setup:
    +            File uploaded = new File(uploadTestTargetDir,PLUGIN_FILE)
    +            def fwksvc = Mock(FrameworkService)
    +
    +            controller.featureService = Mock(FeatureService)
    +            controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
    +            def fwk = Mock(Framework) {
    +                getBaseDir() >> uploadTestBaseDir
    +                getLibextDir() >> uploadTestTargetDir
    +            }
    +            fwksvc.getRundeckFramework() >> fwk
    +            controller.frameworkService = fwksvc
    +            controller.apiService=Mock(ApiService)
    +        when: "request made without synch token"
    +            request.method='POST'
    +            !uploaded.exists()
    +            def pluginInputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(PLUGIN_FILE)
    +            request.addFile(new GrailsMockMultipartFile("pluginFile",PLUGIN_FILE,"application/octet-stream",pluginInputStream))
    +            controller.uploadPlugin()
    +
    +        then:
    +            0 * controller.rundeckAuthContextProcessor.getAuthContextForSubject(_)
    +            1 * controller.featureService.featurePresent(_) >> false
    +            0 * controller.rundeckAuthContextProcessor.authorizeApplicationResourceType(_,_,_) >> true
    +            response.text != '{"msg":"done"}'
    +            !uploaded.exists()
    +            1 * controller.apiService.renderErrorFormat(_,{
    +                it.status== HttpServletResponse.SC_BAD_REQUEST
    +                it.code== 'request.error.invalidtoken.message'
    +            })
    +
    +        cleanup:
    +            uploaded.delete()
    +    }
     
         void "install plugin"() {
             setup:
    @@ -406,8 +483,10 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
             }
             fwksvc.getRundeckFramework() >> fwk
             controller.frameworkService = fwksvc
    -
    +        controller.apiService=Mock(ApiService)
             when:
    +        request.method='POST'
    +        setupFormTokens(params)
             !installed.exists()
             def pluginUrl = Thread.currentThread().getContextClassLoader().getResource(PLUGIN_FILE)
             params.pluginUrl = pluginUrl.toString()
    @@ -422,15 +501,90 @@ class PluginControllerSpec extends Specification implements ControllerUnitTest<P
             cleanup:
             installed.delete()
         }
    +    @Unroll
    +    void "install plugin requires POST method"() {
    +        setup:
    +        File installed = new File(uploadTestTargetDir,PLUGIN_FILE)
    +        def fwksvc = Mock(FrameworkService)
    +
    +            controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
    +        def fwk = Mock(Framework) {
    +            getBaseDir() >> uploadTestBaseDir
    +            getLibextDir() >> uploadTestTargetDir
    +        }
    +        fwksvc.getRundeckFramework() >> fwk
    +        controller.frameworkService = fwksvc
    +        controller.apiService=Mock(ApiService)
    +        when: "request made without POST method"
    +        request.method=method
    +        setupFormTokens(params)
    +        !installed.exists()
    +        def pluginUrl = Thread.currentThread().getContextClassLoader().getResource(PLUGIN_FILE)
    +        params.pluginUrl = pluginUrl.toString()
    +        controller.installPlugin()
    +
    +        then:
    +            response.status==405
    +            0 * controller.rundeckAuthContextProcessor.getAuthContextForSubject(_)
    +            0 * controller.rundeckAuthContextProcessor.authorizeApplicationResourceType(_,_,_) >> true
    +            response.text != '{"msg":"done"}'
    +            !installed.exists()
    +
    +        cleanup:
    +        installed.delete()
    +        where:
    +            method << ['get', 'put', 'delete', 'head']
    +    }
    +
    +    void "install plugin requires synch token"() {
    +        setup:
    +        File installed = new File(uploadTestTargetDir,PLUGIN_FILE)
    +        def fwksvc = Mock(FrameworkService)
    +
    +            controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
    +        def fwk = Mock(Framework) {
    +            getBaseDir() >> uploadTestBaseDir
    +            getLibextDir() >> uploadTestTargetDir
    +        }
    +        fwksvc.getRundeckFramework() >> fwk
    +        controller.frameworkService = fwksvc
    +        controller.apiService=Mock(ApiService)
    +        when: "request made without synch token"
    +        request.method='POST'
    +        !installed.exists()
    +        def pluginUrl = Thread.currentThread().getContextClassLoader().getResource(PLUGIN_FILE)
    +        params.pluginUrl = pluginUrl.toString()
    +        controller.installPlugin()
    +
    +        then:
    +            1 * controller.apiService.renderErrorFormat(_,{
    +                it.status== HttpServletResponse.SC_BAD_REQUEST
    +                it.code== 'request.error.invalidtoken.message'
    +            })
    +            0 * controller.rundeckAuthContextProcessor.getAuthContextForSubject(_)
    +            0 * controller.rundeckAuthContextProcessor.authorizeApplicationResourceType(_,_,_) >> true
    +            response.text != '{"msg":"done"}'
    +            !installed.exists()
    +        cleanup:
    +        installed.delete()
    +    }
    +
    +    protected setupFormTokens(params) {
    +        def token = SynchronizerTokensHolder.store(session)
    +        params[SynchronizerTokensHolder.TOKEN_KEY] = token.generateToken('/test')
    +        params[SynchronizerTokensHolder.TOKEN_URI] = '/test'
    +    }
     
         void "unauthorized install plugin fails"() {
             setup:
             controller.frameworkService = Mock(FrameworkService)
     
                 controller.rundeckAuthContextProcessor = Mock(AppAuthContextProcessor)
             messageSource.addMessage("request.error.unauthorized.title",Locale.ENGLISH,"Unauthorized")
    -
    +        controller.apiService=Mock(ApiService)
             when:
    +        request.method='POST'
    +        setupFormTokens(params)
             def pluginUrl = Thread.currentThread().getContextClassLoader().getResource(PLUGIN_FILE)
             params.pluginUrl = pluginUrl.toString()
             controller.installPlugin()
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.