Skip to content

Commit

Permalink
feat: adds HMAC signature verification (#28)
Browse files Browse the repository at this point in the history
closes #11
  • Loading branch information
igpetrov authored and chillleader committed Jul 26, 2023
1 parent aa325ad commit 9876aef
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.camunda.connector.inbound.configs;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LocalContextBeanConfiguration {

@Bean
public ObjectMapper jacksonMapper() {
return new ObjectMapper();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.camunda.connector.inbound.security.signature;

public enum HMACAlgoCustomerChoice {

sha_1("HmacSHA1", "sha1"),
sha_256("HmacSHA256", "sha256"),
sha_512("HmacSHA512", "sha512");

private final String algoReference;
private final String tag;

HMACAlgoCustomerChoice(final String algoReference, final String tag) {
this.algoReference = algoReference;
this.tag = tag;
}

public String getAlgoReference() {
return algoReference;
}

public String getTag() {
return tag;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package io.camunda.connector.inbound.security.signature;

import org.apache.commons.codec.binary.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StreamUtils;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;

// TODO: add URL signing and Base64 format
public class HMACSignatureValidator {

private static final Logger LOG = LoggerFactory.getLogger(HMACSignatureValidator.class);

private final byte[] requestBody;
private final Map<String, String> headers;
private final String hmacHeader;
private final String hmacSecretKey;
private final HMACAlgoCustomerChoice hmacAlgo;

public HMACSignatureValidator(
final byte[] requestBody,
final Map<String, String> headers,
final String hmacHeader,
final String hmacSecretKey,
final HMACAlgoCustomerChoice hmacAlgo) {
this.requestBody = requestBody;
this.headers = headers;
this.hmacHeader = hmacHeader;
this.hmacSecretKey = hmacSecretKey;
this.hmacAlgo = hmacAlgo;
}

public boolean isRequestValid() throws NoSuchAlgorithmException, InvalidKeyException {
final String providedHmac = headers.get(hmacHeader);

LOG.debug("Given HMAC from webhook call: {}", providedHmac);

byte[] signedEntity = requestBody;

Mac sha256_HMAC = Mac.getInstance(hmacAlgo.getAlgoReference());
SecretKeySpec secret_key =
new SecretKeySpec(hmacSecretKey.getBytes(StandardCharsets.UTF_8), hmacAlgo.getAlgoReference());
sha256_HMAC.init(secret_key);
byte[] expectedHmac = sha256_HMAC.doFinal(signedEntity);

// Some webhooks produce short HMAC message, e.g. aabbcc...
String expectedShortHmacString = Hex.encodeHexString(expectedHmac);
// The other produce longer version, like sha256=aabbcc...
String expectedLongHmacString = hmacAlgo.getTag() + "=" + expectedShortHmacString;
LOG.debug("Computed HMAC from webhook body: {}, {}", expectedShortHmacString, expectedLongHmacString);

return providedHmac.equals(expectedShortHmacString) || providedHmac.equals(expectedLongHmacString);
}

}
Original file line number Diff line number Diff line change
@@ -1,38 +1,54 @@
package io.camunda.connector.inbound.webhook;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.camunda.connector.inbound.feel.FeelEngineWrapper;
import io.camunda.connector.inbound.registry.InboundConnectorRegistry;
import io.camunda.connector.inbound.security.signature.HMACAlgoCustomerChoice;
import io.camunda.connector.inbound.security.signature.HMACSignatureValidator;
import io.camunda.zeebe.client.ZeebeClient;
import io.camunda.zeebe.client.api.response.ProcessInstanceEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;

@RestController
public class InboundWebhookRestController {

private static final Logger LOG = LoggerFactory.getLogger(InboundWebhookRestController.class);

@Autowired
private InboundConnectorRegistry registry;

@Autowired
private ZeebeClient zeebeClient;

@Autowired
private FeelEngineWrapper feelEngine;
private final InboundConnectorRegistry registry;
private final ZeebeClient zeebeClient;
private final FeelEngineWrapper feelEngine;
private final ObjectMapper jsonMapper;

public InboundWebhookRestController(
final InboundConnectorRegistry registry,
final ZeebeClient zeebeClient,
final FeelEngineWrapper feelEngine,
final ObjectMapper jsonMapper) {
this.registry = registry;
this.zeebeClient = zeebeClient;
this.feelEngine = feelEngine;
this.jsonMapper = jsonMapper;
}

@PostMapping("/inbound/{context}")
public ResponseEntity<ProcessInstanceEvent> inbound(
@PathVariable String context,
@RequestBody Map<String, Object> body,
@RequestHeader Map<String, String> headers) {
@RequestBody byte[] bodyAsByteArray, // it is important to get pure body in order to recalculate HMAC
@RequestHeader Map<String, String> headers) throws IOException {

LOG.debug("Received inbound hook on {}", context);

Expand All @@ -41,12 +57,14 @@ public ResponseEntity<ProcessInstanceEvent> inbound(
}
WebhookConnectorProperties connectorProperties = registry.getWebhookConnectorByContextPath(context);

// TODO: what context do we expose?
// TODO(nikku): what context do we expose?
// TODO(igpetrov): handling exceptions? Throw or fail? Maybe spring controller advice?
Map bodyAsMap = jsonMapper.readValue(bodyAsByteArray, Map.class);
final Map<String, Object> webhookContext =
Map.of(
"request",
Map.of(
"body", body,
"body", bodyAsMap,
"headers", headers));

final var valid = validateSecret(connectorProperties, webhookContext);
Expand All @@ -56,6 +74,17 @@ public ResponseEntity<ProcessInstanceEvent> inbound(
return ResponseEntity.status(400).build();
}

try {
// TODO(igpetrov): currently in test mode. Don't enforce for now.
final var isHmacValid = isValidHmac(connectorProperties, bodyAsByteArray, headers);
LOG.debug("Test mode: validating HMAC. Was {}", isHmacValid);
} catch (NoSuchAlgorithmException e) {
LOG.error("Wasn't able to recognise HMAC algorithm {}", connectorProperties.getHMACAlgo());
} catch (InvalidKeyException e) {
// FIXME: remove exposure of secret key when prototyping complit
LOG.error("Secret key '{}' was invalid", connectorProperties.getHMACSecret());
}

final var shouldActivate = checkActivation(connectorProperties, webhookContext);
if (!shouldActivate) {
LOG.debug("Should not activate {} :: {}", context, webhookContext);
Expand All @@ -78,6 +107,25 @@ public ResponseEntity<ProcessInstanceEvent> inbound(
return ResponseEntity.status(HttpStatus.CREATED).body(processInstanceEvent);
}

private boolean isValidHmac(final WebhookConnectorProperties connectorProperties,
final byte[] bodyAsByteArray,
final Map<String, String> headers)
throws NoSuchAlgorithmException, InvalidKeyException {
if ("disabled".equals(connectorProperties.shouldValidateHMAC())) {
return true;
}

HMACSignatureValidator validator = new HMACSignatureValidator(
bodyAsByteArray,
headers,
connectorProperties.getHMACHeader(),
connectorProperties.getHMACSecret(),
HMACAlgoCustomerChoice.valueOf(connectorProperties.getHMACAlgo())
);

return validator.isRequestValid();
}

private ProcessInstanceEvent startInstance(
WebhookConnectorProperties connectorProperties, Map<String, Object> variables) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ public String getVariableMapping() {
return genericProperties.getProperties().get("inbound.variableMapping");
}

// Security / HMAC Validation

// Dropdown that indicates whether customer wants to validate webhook request with HMAC. Values: enabled | disabled
public String shouldValidateHMAC() {
return genericProperties.getProperties().getOrDefault("inbound.shouldValidateHmac", "disabled");
}
// HMAC secret token. An arbitrary String, example 'mySecretToken'. Is it the same as getSecret(...)?
public String getHMACSecret() {
return genericProperties.getProperties().get("inbound.hmacSecret");
}
// Indicates which header is used to store HMAC signature. Example, X-Hub-Signature-256
public String getHMACHeader() {
return genericProperties.getProperties().get("inbound.hmacHeader");
}
// Indicates which algorithm was used to produce HMAC signature. Should correlate enum names of io.camunda.connector.inbound.security.signature.HMACAlgoCustomerChoice
public String getHMACAlgo() {
return genericProperties.getProperties().get("inbound.hmacAlgorithm");
}

public String getBpmnProcessId() {
return genericProperties.getBpmnProcessId();
}
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
camunda.operate.client.url=http://localhost:8081
camunda.operate.client.username=demo
camunda.operate.client.password=demo
#server.port=9898

# See io.camunda.connector.inbound.operate.OperateClientFactory for more details on config Options
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.camunda.connector.inbound.security.signature;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.io.File;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.stream.Stream;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.Files.readString;

class HMACSignatureValidatorTest {

private static final String GH_SHA1_HEADER = "X-Hub-Signature";
private static final String GH_SHA1_VALUE = "sha1=de81c837cc792e7d21d7bf9feb74cd19d714baca";
private static final String GH_SHA256_HEADER = "X-Hub-Signature-256";
private static final String GH_SHA256_LONG_VALUE = "sha256=dd22cfb7ae96875d81bd1a695a0244f2b4c32c0938be0b445f520b0b3e0f43fd";
private static final String GH_SHA256_SHORT_VALUE = "dd22cfb7ae96875d81bd1a695a0244f2b4c32c0938be0b445f520b0b3e0f43fd";
private static final String GH_SECRET_KEY = "mySecretKey";


@ParameterizedTest
@MethodSource("provideHMACTestData")
public void hmacSignatureVerificationParametrizedTest(final HMACTestEntry testEntry)
throws IOException, NoSuchAlgorithmException, InvalidKeyException {
HMACSignatureValidator validator = new HMACSignatureValidator(
readString(new File(testEntry.filepathWithBody).toPath(), UTF_8).getBytes(UTF_8),
testEntry.originalRequestHeaders,
testEntry.headerWithHmac,
testEntry.decodedSecretKey,
testEntry.algo
);
Assertions.assertThat(validator.isRequestValid()).isTrue();
}

private static Stream<HMACTestEntry> provideHMACTestData() {
return Stream.of(
new HMACTestEntry(
"src/test/resources/hmac/gh-webhook-request.json",
Map.of(GH_SHA256_HEADER, GH_SHA256_LONG_VALUE),
GH_SHA256_HEADER,
GH_SECRET_KEY,
HMACAlgoCustomerChoice.sha_256),
new HMACTestEntry(
"src/test/resources/hmac/gh-webhook-request.json",
Map.of(GH_SHA1_HEADER, GH_SHA1_VALUE),
GH_SHA1_HEADER,
GH_SECRET_KEY,
HMACAlgoCustomerChoice.sha_1),
new HMACTestEntry(
"src/test/resources/hmac/gh-webhook-request.json",
Map.of(GH_SHA256_HEADER, GH_SHA256_SHORT_VALUE),
GH_SHA256_HEADER,
GH_SECRET_KEY,
HMACAlgoCustomerChoice.sha_256)
);
}

private static class HMACTestEntry {
final String filepathWithBody;
final Map<String, String> originalRequestHeaders;
final String headerWithHmac;
final String decodedSecretKey;
final HMACAlgoCustomerChoice algo;

public HMACTestEntry(String filepathWithBody,
Map<String, String> originalRequestHeaders,
String headerWithHmac,
String decodedSecretKey,
HMACAlgoCustomerChoice algo) {
this.filepathWithBody = filepathWithBody;
this.originalRequestHeaders = originalRequestHeaders;
this.headerWithHmac = headerWithHmac;
this.decodedSecretKey = decodedSecretKey;
this.algo = algo;
}
}

}
Loading

0 comments on commit 9876aef

Please sign in to comment.