Skip to content

Commit

Permalink
CID-2777: Secure webhook listener endpoint - Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
mohamedlajmileanix committed Aug 8, 2024
1 parent 63070e8 commit e403039
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 13 deletions.
2 changes: 2 additions & 0 deletions config/detekt/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ style:
active: false
UnusedPrivateMember:
active: false
ThrowsCount:
active: false

empty-blocks:
EmptyFunctionBlock:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ data class GitHubEnterpriseProperties(
val gitHubAppId: String,
val pemFile: String,
val manifestFileDirectory: String,
val webhookSecret: String
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package net.leanix.githubagent.controllers

import net.leanix.githubagent.services.WebhookService
import net.leanix.githubagent.shared.SUPPORTED_EVENT_TYPES
import org.slf4j.LoggerFactory
import net.leanix.githubagent.services.GitHubWebhookService
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
Expand All @@ -13,22 +11,16 @@ import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("github")
class GitHubWebhookController(private val webhookService: WebhookService) {

private val logger = LoggerFactory.getLogger(GitHubWebhookController::class.java)
class GitHubWebhookController(private val gitHubWebhookService: GitHubWebhookService) {

@PostMapping("/webhook")
@ResponseStatus(HttpStatus.ACCEPTED)
fun hook(
@RequestHeader("X-Github-Event") eventType: String,
@RequestHeader("X-GitHub-Enterprise-Host") host: String,
@RequestHeader("X-Hub-Signature-256", required = false) signature256: String?,
@RequestBody payload: String
) {
runCatching {
if (SUPPORTED_EVENT_TYPES.contains(eventType.uppercase())) {
webhookService.consumeWebhookEvent(eventType, payload)
} else {
logger.warn("Received an unsupported event of type: $eventType")
}
}
gitHubWebhookService.processWebhookEvent(eventType, host, signature256, payload)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package net.leanix.githubagent.controllers.advice

import net.leanix.githubagent.exceptions.InvalidEventSignatureException
import net.leanix.githubagent.exceptions.WebhookSecretNotSetException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler

@ControllerAdvice
class GlobalExceptionHandler {

val exceptionLogger: Logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)

@ExceptionHandler(InvalidEventSignatureException::class)
fun handleInvalidEventSignatureException(exception: InvalidEventSignatureException): ProblemDetail {
val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid event signature")
problemDetail.title = exception.message
exceptionLogger.warn(exception.message)
return problemDetail
}

@ExceptionHandler(WebhookSecretNotSetException::class)
fun handleWebhookSecretNotSetException(exception: WebhookSecretNotSetException): ProblemDetail {
val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Webhook secret not set")
problemDetail.title = exception.message
return problemDetail
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ class UnableToConnectToGitHubEnterpriseException(message: String) : RuntimeExcep
class JwtTokenNotFound : RuntimeException("JWT token not found")
class GraphQLApiException(errors: List<GraphQLClientError>) :
RuntimeException("Errors: ${errors.joinToString(separator = "\n") { it.message }}")
class WebhookSecretNotSetException : RuntimeException("Webhook secret not set")
class InvalidEventSignatureException : RuntimeException("Invalid event signature")
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package net.leanix.githubagent.services

import net.leanix.githubagent.config.GitHubEnterpriseProperties
import net.leanix.githubagent.exceptions.InvalidEventSignatureException
import net.leanix.githubagent.exceptions.WebhookSecretNotSetException
import net.leanix.githubagent.shared.SUPPORTED_EVENT_TYPES
import net.leanix.githubagent.shared.hmacSHA256
import net.leanix.githubagent.shared.timingSafeEqual
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class GitHubWebhookService(
private val webhookService: WebhookService,
private val gitHubEnterpriseProperties: GitHubEnterpriseProperties
) {

private val logger = LoggerFactory.getLogger(GitHubWebhookService::class.java)
private var isWebhookProcessingEnabled = true

fun processWebhookEvent(eventType: String, host: String, signature256: String?, payload: String) {
if (!isWebhookProcessingEnabled) {
throw WebhookSecretNotSetException()
}
if (!gitHubEnterpriseProperties.baseUrl.contains(host)) {
logger.error("Received a webhook event from an unknown host: $host")
return
}
if (gitHubEnterpriseProperties.webhookSecret == "" && !signature256.isNullOrEmpty()) {
logger.error(
"Event signature is present but webhook secret is not set, " +
"please restart the agent with a valid secret"
)
isWebhookProcessingEnabled = false
throw WebhookSecretNotSetException()
}
if (gitHubEnterpriseProperties.webhookSecret != "" && !signature256.isNullOrEmpty()) {
val hashedSecret = hmacSHA256(gitHubEnterpriseProperties.webhookSecret, payload)
val isEqual = timingSafeEqual(signature256.removePrefix("sha256="), hashedSecret)
if (!isEqual) throw InvalidEventSignatureException()
} else {
logger.warn("Webhook secret is not set, Skipping signature verification")
}
if (SUPPORTED_EVENT_TYPES.contains(eventType.uppercase())) {
webhookService.consumeWebhookEvent(eventType, payload)
} else {
logger.warn("Received an unsupported event of type: $eventType")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package net.leanix.githubagent.shared

import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

fun hmacSHA256(secret: String, data: String): String {
val secretKey = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
mac.init(secretKey)
val hmacData = mac.doFinal(data.toByteArray())
return hmacData.joinToString("") { "%02x".format(it) }
}

fun timingSafeEqual(a: String, b: String): Boolean {
val aBytes = a.toByteArray()
val bBytes = b.toByteArray()
if (aBytes.size != bBytes.size) return false

var diff = 0
for (i in aBytes.indices) {
diff = diff or (aBytes[i].toInt() xor bBytes[i].toInt())
}
return diff == 0
}
1 change: 1 addition & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ github-enterprise:
githubAppId: ${GITHUB_APP_ID:}
pemFile: ${PEM_FILE:}
manifestFileDirectory: ${MANIFEST_FILE_DIRECTORY:}
webhookSecret: ${WEBHOOK_SECRET:}
leanix:
base-url: https://${LEANIX_DOMAIN}/services
ws-base-url: wss://${LEANIX_DOMAIN}/services/git-integrations/v1/git-ws
Expand Down

0 comments on commit e403039

Please sign in to comment.