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.
| Package | Affected versions | Patched versions |
|---|---|---|
org.rundeck:rundeck-coreMaven | >= 3.4.0, < 3.4.3 | 3.4.3 |
org.rundeck:rundeck-coreMaven | < 3.3.14 | 3.3.14 |
Affected products
1Patches
167c4eedeaf95Merge pull request from GHSA-3jmw-c69h-426c
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- github.com/advisories/GHSA-3jmw-c69h-426cghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-39133ghsaADVISORY
- github.com/rundeck/rundeck/commit/67c4eedeaf9509fc0b255aff15977a5229ef13b9ghsax_refsource_MISCWEB
- github.com/rundeck/rundeck/security/advisories/GHSA-3jmw-c69h-426cghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.