Skip to content

Commit

Permalink
RCLOUD-181 Add the ability to authenticate webhooks using the Authori…
Browse files Browse the repository at this point in the history
…zation header
  • Loading branch information
sjrd218 committed Jan 24, 2022
1 parent 8b92c1d commit b728e83
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.dtolabs.rundeck.core.authorization.AuthContextEvaluator
import com.dtolabs.rundeck.core.authorization.AuthContextProvider
import com.dtolabs.rundeck.core.authorization.AuthorizationUtil
import com.dtolabs.rundeck.core.authorization.UserAndRolesAuthContext
import com.dtolabs.rundeck.core.storage.ResourceMeta
import com.dtolabs.rundeck.core.webhook.WebhookEventException
import com.dtolabs.rundeck.plugins.webhook.WebhookDataImpl
import com.fasterxml.jackson.databind.ObjectMapper
Expand All @@ -15,13 +16,14 @@ import org.rundeck.core.auth.AuthConstants
import javax.servlet.http.HttpServletResponse

class WebhookController {
static final String AUTH_HEADER = "Authorization"
static allowedMethods = [post:'POST']

def webhookService
def frameworkService
AuthContextEvaluator rundeckAuthContextEvaluator
AuthContextProvider rundeckAuthContextProvider
def apiService
def storageService

def admin() {}

Expand Down Expand Up @@ -146,6 +148,11 @@ class WebhookController {
return
}

if(!authorizeWebhookSecret(authContext, hook, request.getHeader(AUTH_HEADER))) {
sendJsonError("Failed webhook authorization")
return
}

WebhookDataImpl whkdata = new WebhookDataImpl()
whkdata.webhookUUID = hook.uuid
whkdata.webhook = hook.name
Expand Down Expand Up @@ -175,4 +182,24 @@ class WebhookController {
if(action != AuthConstants.ACTION_ADMIN) authorizedActions.add(action)
rundeckAuthContextEvaluator.authorizeProjectResourceAny(authContext,AuthConstants.RESOURCE_TYPE_WEBHOOK,authorizedActions,project)
}

@PackageScope
boolean authorizeWebhookSecret(AuthContext authContext, Webhook hook, String headerAuthValue) {
if(!hook.secret || hook.secret.isEmpty()) return true
if(hook.secret.startsWith("keys/")) {
try {
def keyStorageService = storageService.storageTreeWithContext(authContext)
ResourceMeta contents = keyStorageService.getResource(hook.secret).getContents();
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
contents.writeContent(byteArrayOutputStream);

return headerAuthValue == new String(byteArrayOutputStream.toByteArray());
} catch (IOException e) {
log.warn("Failed to get webhook storage key: ${hook.secret}", e)
return false
}
} else {
return hook.secret == headerAuthValue
}
}
}
2 changes: 2 additions & 0 deletions grails-webhooks/grails-app/domain/webhooks/Webhook.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ class Webhook {
String name
String project
String authToken
String secret
String eventPlugin
String pluginConfigurationJson = '{}'
boolean enabled = true

static constraints = {
uuid(nullable: true)
name(nullable: false)
secret(nullable: true)
project(nullable: false)
authToken(nullable: false)
eventPlugin(nullable: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class WebhookService {
hook.uuid = hookData.uuid ?: hook.uuid
hook.name = hookData.name ?: hook.name
hook.project = hookData.project ?: hook.project
if(hookData.secret != null) hook.secret = hookData.secret
if(hookData.enabled != null) hook.enabled = hookData.enabled
if(hookData.eventPlugin && !pluginService.listPlugins(WebhookEventPlugin).any { it.key == hookData.eventPlugin}){
hook.discard()
Expand Down Expand Up @@ -300,7 +301,7 @@ class WebhookService {

private Map getWebhookWithAuthAsMap(Webhook hook) {
AuthenticationToken authToken = rundeckAuthTokenManagerService.getToken(hook.authToken)
return [id:hook.id, uuid:hook.uuid, name:hook.name, project: hook.project, enabled: hook.enabled, user:authToken.ownerName, creator:authToken.creator, roles: authToken.authRolesSet().join(","), authToken:hook.authToken, eventPlugin:hook.eventPlugin, config:mapper.readValue(hook.pluginConfigurationJson, HashMap)]
return [id:hook.id, uuid:hook.uuid, name:hook.name, project: hook.project, enabled: hook.enabled, user:authToken.ownerName, creator:authToken.creator, roles: authToken.authRolesSet().join(","), authToken:hook.authToken, secret: hook.secret, eventPlugin:hook.eventPlugin, config:mapper.readValue(hook.pluginConfigurationJson, HashMap)]
}

Webhook getWebhook(Long id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ import com.dtolabs.rundeck.core.authorization.AuthContextEvaluator
import com.dtolabs.rundeck.core.authorization.AuthContextProvider
import com.dtolabs.rundeck.core.authorization.SubjectAuthContext
import com.dtolabs.rundeck.core.authorization.UserAndRolesAuthContext
import com.dtolabs.rundeck.core.storage.ResourceMeta
import com.dtolabs.rundeck.plugins.webhook.DefaultJsonWebhookResponder
import com.dtolabs.rundeck.plugins.webhook.DefaultWebhookResponder
import com.dtolabs.rundeck.plugins.webhook.WebhookDataImpl
import com.dtolabs.rundeck.plugins.webhook.WebhookResponder
import grails.testing.web.controllers.ControllerUnitTest
import org.rundeck.core.auth.AuthConstants
import org.rundeck.storage.api.Resource
import spock.lang.Specification
import spock.lang.Unroll

import javax.rmi.CORBA.Stub
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

Expand Down Expand Up @@ -79,6 +83,51 @@ class WebhookControllerSpec extends Specification implements ControllerUnitTest<
response.text == '{"err":"You are not authorized to perform this action"}'
}

@Unroll
def "test post Authorization header and secret"() {
given:
controller.rundeckAuthContextProvider = Mock(AuthContextProvider)
controller.rundeckAuthContextEvaluator = Mock(AuthContextEvaluator)
controller.webhookService = Mock(MockWebhookService)
def mockResourceMeta = Mock(ResourceMeta) {
writeContent(_ as OutputStream) >> {
OutputStream out = it[0]
out.write(keystorevalue.bytes)
return keystorevalue.bytes.size()
}
}
def mockResource = Mock(Resource) {
getContents() >> mockResourceMeta
}
def mockTree = Mock(MockStorageTree) {
getResource(_) >> mockResource
}
controller.storageService = Mock(MockStorageService)

when:
request.addHeader(WebhookController.AUTH_HEADER, secretHeader)
params.authtoken = "1234"
request.method = 'POST'
controller.post()

then:
1 * controller.webhookService.getWebhookByToken(_) >> { new Webhook(name:"test",authToken: "1234", secret: secret)}
1 * controller.rundeckAuthContextProvider.getAuthContextForSubjectAndProject(_, _) >> { new SubjectAuthContext(null, null) }
1 * controller.rundeckAuthContextEvaluator.authorizeProjectResourceAny(_,_,_,_) >> { return true }
ssCount * controller.storageService.storageTreeWithContext(_) >> { mockTree }
phCount * controller.webhookService.processWebhook(_,_,_,_,_) >> { new DefaultWebhookResponder() }
response.text == expectedMsg

where:
secret | secretHeader | keystorevalue | ssCount | expectedMsg | phCount
"AuthMe!!" | "AuthMe!!" | null | 0 | 'ok' | 1
"AuthMe!!" | "something" | null | 0 | '{"err":"Failed webhook authorization"}' | 0
"keys/hooks/hk1" | "AuthMe!!" | 'AuthMe!!' | 1 | 'ok' | 1
"keys/hooks/hk1" | "AuthMe!!" | 'wonmatch' | 1 | '{"err":"Failed webhook authorization"}' | 0
"keys/hooks/hk1" | "wrongval" | 'AuthMe!!' | 1 | '{"err":"Failed webhook authorization"}' | 0
"wontmatch" | "AuthMe!!" | null | 0 | '{"err":"Failed webhook authorization"}' | 0
}

def "remove webhook should fail when project params is not present"() {
given:
controller.webhookService = Mock(MockWebhookService) {
Expand Down Expand Up @@ -165,6 +214,13 @@ class WebhookControllerSpec extends Specification implements ControllerUnitTest<
'DELETE' | 405 | 0
}

interface MockStorageService {
Object storageTreeWithContext(Object obj)
}
interface MockStorageTree {
Resource getResource(String path)
}

interface MockWebhookService {
Webhook getWebhookByToken(String token)
Webhook getWebhook(Long id)
Expand Down
10 changes: 10 additions & 0 deletions rundeckapp/grails-app/migrations/core/Webhook.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,14 @@ databaseChangeLog = {
}
}
}
changeSet(author: "Stephen Joyner", id: "3.4.11-webhook-secret") {
preConditions(onFail: "MARK_RAN") {
not {
columnExists(tableName: "webhook", columnName: 'secret')
}
}
addColumn(tableName: "webhook") {
column(name: 'secret', type: '${varchar255.type}')
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export class Webhook {
@observable project!: string
@observable roles!: string
@observable user!: string
@observable secret!: string
@observable eventPluginName!: string
@observable eventPlugin?: Plugin

Expand All @@ -174,6 +175,7 @@ export class Webhook {
this.project = json.project
this.roles = json.roles
this.user = json.user
this.secret = json.secret
this.eventPluginName = json.eventPlugin
}

Expand All @@ -191,6 +193,7 @@ export class Webhook {
project: this.project,
roles: this.roles,
user: this.user,
secret: this.secret,
eventPlugin: this.eventPlugin?.name
}
}
Expand Down
2 changes: 2 additions & 0 deletions rundeckapp/grails-spa/packages/ui/src/pages/webhooks/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const translationStrings = {
webhookUserHelp:'The authorization username assumed when running this webhook. All ACL policies matching this username will apply.',
webhookRolesLabel:'Roles',
webhookRolesHelp:'The authorization roles assumed when running this webhook (comma separated). All ACL policies matching these roles will apply.',
webhookSecretLabel:'Secret',
webhookSecretHelp:'If you want to authenticate the webhook, put a value in this field or store the value in the key store. All posts to this webhook must have the secret in the Authorization header.',
webhookPluginLabel:'Choose Webhook Plugin'
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@click="handleAddNew"><i class="fas fa-plus-circle"/> {{ $t('message.webhookCreateBtn') }}</a>
</div>
</div>

<div style="display: flex; height: 100%;overflow: hidden;">
<div id="wh-list" style="flex-basis: 250px;flex-grow: 0; padding: 20px;overflow-x: hidden;overflow-y: auto;">
<WebhookPicker :selected="curHook ? curHook.uuid : ''" :project="projectName" @item:selected="(item) => handleSelect(item)"/>
Expand Down Expand Up @@ -74,6 +74,14 @@
{{$t('message.webhookRolesHelp')}}
</div>
</div>
<div class="form-group"><label>{{ $t('message.webhookSecretLabel') }}</label><input v-model="curHook.secret" class="form-control">
<key-storage-selector :value="secretInput"
:allow-upload="true"
v-on:input="handleKeyStorage($event)"/>
<div class="help-block">
{{$t('message.webhookSecretHelp')}}
</div>
</div>
<div class="form-group">
<div class="checkbox"><input type="checkbox" v-model="curHook.enabled" class="form-control"><label>{{ $t('message.webhookEnabledLabel') }}</label></div>
</div>
Expand Down Expand Up @@ -159,6 +167,7 @@ import CopyBox from '@rundeck/ui-trellis/lib/components/containers/copybox/CopyB
import Tabs from '@rundeck/ui-trellis/lib/components/containers/tabs/Tabs'
import Tab from '@rundeck/ui-trellis/lib/components/containers/tabs/Tab'
import WebhookPicker from '@rundeck/ui-trellis/lib/components/widgets/webhook-select/WebhookSelect.vue'
import KeyStorageSelector from '@rundeck/ui-trellis/lib/components/plugins/KeyStorageSelector.vue'
import {getServiceProviderDescription,
getPluginProvidersForService} from '@rundeck/ui-trellis/lib/modules/pluginService'
Expand Down Expand Up @@ -199,7 +208,8 @@ export default observer(Vue.extend({
Tabs,
Tab,
WebhookPicker,
WebhookTitle
WebhookTitle,
KeyStorageSelector
},
inject: ["rootStore"],
data() {
Expand All @@ -218,7 +228,15 @@ export default observer(Vue.extend({
dirty: false
}
},
computed: {
secretInput() {
return this.curHook.secret ? this.curHook.secret : ''
}
},
methods: {
handleKeyStorage(val) {
Vue.set(this.curHook, "secret", val)
},
input() {
this.dirty = true
},
Expand Down Expand Up @@ -280,7 +298,7 @@ export default observer(Vue.extend({
}, (data) => {
this.dirty = true
})
this.setValidation(true)
this.setSelectedPlugin(true)
},
Expand Down

0 comments on commit b728e83

Please sign in to comment.