Skip to content

Commit

Permalink
Validate signature header for incoming POST requests (#42)
Browse files Browse the repository at this point in the history
Implement various utility methods for validating inngest signatures

public

    `checkHeadersAndValidateSignature`: this can be called from code serving Inngest's request
    If this function finishes without an exception, the signature is valid

private/internal

    computing HMAC given a hex encoded key and body
    sign request body + timestamp using signing key in Inngest format (`signkey-<env>-<key>`)
    validate full signature header including format, timestamp within last 5 minutes, and signature itself

Add call to `checkHeadersAndValidateSignature` to spring boot adapter, currently only for POST
  • Loading branch information
albertchae authored Feb 28, 2024
1 parent ece6e85 commit 63e02f8
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.inngest.signingkey

import com.inngest.InngestEnv
import com.inngest.ServeConfig
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

const val HMAC_SHA256 = "HmacSHA256"

// Implementation of this is inspired by the pure Java example from https://www.baeldung.com/java-hmac#hmac-using-jdk-apis
@OptIn(ExperimentalStdlibApi::class)
private fun computeHMAC(
algorithm: String,
data: String,
key: String,
): String {
val secretKeySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), algorithm)
val mac = Mac.getInstance(algorithm)
mac.init(secretKeySpec)
return mac.doFinal(data.toByteArray(Charsets.UTF_8)).toHexString()
}

internal fun signRequest(
requestBody: String,
timestamp: Long,
signingKey: String,
): String {
return signRequest(requestBody, timestamp.toString(), signingKey)
}

private fun signRequest(
requestBody: String,
timestamp: String,
signingKey: String,
): String {
val matchResult = SIGNING_KEY_REGEX.matchEntire(signingKey) ?: throw InvalidSigningKeyException()
val key = matchResult.groups["key"]!!.value
val message = requestBody + timestamp

return computeHMAC(HMAC_SHA256, message, key)
}

class InvalidSignatureHeaderException(message: String) : Throwable(message)

class ExpiredSignatureHeaderException : Throwable("signature header has expired")

const val FIVE_MINUTES_IN_SECONDS = 5L * 60

internal fun validateSignature(
signatureHeader: String,
signingKey: String,
requestBody: String,
) {
// TODO: Find a way to parse signatureHeader as URL params without constructing a full URL
val dummyUrl = "https://test.inngest.com/?$signatureHeader"
val url = dummyUrl.toHttpUrlOrNull() ?: throw InvalidSignatureHeaderException("signature header does not match expected format")
val timestamp = url.queryParameter("t")?.toLongOrNull() ?: throw InvalidSignatureHeaderException("timestamp is invalid")
val signature = url.queryParameter("s") ?: throw InvalidSignatureHeaderException("signature is invalid")

val fiveMinutesAgo = Instant.now().minusSeconds(FIVE_MINUTES_IN_SECONDS).epochSecond
if (timestamp < fiveMinutesAgo) {
throw ExpiredSignatureHeaderException()
}

val actualSignature = signRequest(requestBody, timestamp, signingKey)
if (actualSignature != signature) {
throw InvalidSignatureHeaderException("signature is invalid")
}
}

/**
* A function to check if the signature header is valid for a given body. This function completes
* with Unit if everything is valid, otherwise it'll throw a relevant exception
*
* @param signatureHeader The `X-Inngest-Signature` header in the format "t=<seconds_since_unix_epoch>&s=<signature>"
* @param requestBody The request body as a string
* @param serverKind The `X-Inngest-Server-Kind` header, either "dev" or "cloud"
* @param config The current ServeConfig instance, holds relevant environment configuration
*/
fun checkHeadersAndValidateSignature(
signatureHeader: String?,
requestBody: String,
serverKind: String?,
config: ServeConfig,
) {
val useDevServer = config.client.env == InngestEnv.Dev

// exit early without checking signature if we are using dev server
if (useDevServer) {
if (serverKind != "dev") {
// TODO: Use a real logger
println("WARNING: using dev server but received X-Inngest-Server-Kind: $serverKind")
}
return
}

val signingKey = config.signingKey()

signatureHeader ?: throw InvalidSignatureHeaderException("Using cloud inngest but did not receive X-Inngest-Signature")

validateSignature(signatureHeader, signingKey, requestBody)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.inngest.signingkey

import com.inngest.Inngest
import com.inngest.ServeConfig
import org.junit.jupiter.api.assertDoesNotThrow
import java.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class SignatureVerificationKtTest {
val testBody = "hey! if you're reading this come work with us: [email protected]"
val testKey = "signkey-test-12345678"

@Test
fun `signRequest produces same signatures with different prefix keys`() {
val ts = 1709026298L
val a = signRequest(testBody, ts, "signkey-test-12345678")
val b = signRequest(testBody, ts, "signkey-prod-12345678")
val c = signRequest(testBody, ts, "signkey-staging-12345678")

assertEquals("3f1c811920eb25da7fa70e3ac484e32e93f01dbbca7c9ce2365f2062a3e10c26", a)
assertEquals(a, b)
assertEquals(a, c)
}

@Test
fun `fails with an invalid signature header`() {
assertFailsWith<InvalidSignatureHeaderException> {
validateSignature("lol", testKey, testBody)
}
}

@Test
fun `fails with an invalid timestamp`() {
assertFailsWith<InvalidSignatureHeaderException> {
validateSignature("t=what&s=yea", testKey, testBody)
}
}

@Test
fun `fails with an expired timestamp`() {
val now = Instant.now().epochSecond
val oneHourAgo = now - 60 * 60
assertFailsWith<ExpiredSignatureHeaderException> {
validateSignature("t=$oneHourAgo&s=yea", testKey, testBody)
}
}

@Test
fun `fails with a signing key that wasn't the one used to create the signature`() {
val now = Instant.now().epochSecond
val signature = signRequest(testBody, now, testKey)

assertFailsWith<InvalidSignatureHeaderException> {
validateSignature("t=$now&s=$signature", "signkey-test-badkey", testBody)
}
}

@Test
fun `validateSignature succeeds if signature matches and timestamp is within a reasonable time`() {
val now = Instant.now().epochSecond
val signature = signRequest(testBody, now, testKey)

assertDoesNotThrow {
validateSignature("t=$now&s=$signature", testKey, testBody)
}
}

@Test
fun `succeeds without signing key or signature if in dev`() {
val testConfig =
ServeConfig(
Inngest("unit-test", env = "dev"),
signingKey = null,
)
assertDoesNotThrow {
checkHeadersAndValidateSignature(
null,
testBody,
null,
testConfig,
)
}
}

@Test
fun `fails if in prod and signing key is missing`() {
val testConfig =
ServeConfig(
Inngest("unit-test", env = "prod"),
signingKey = null,
)
val exception =
assertFailsWith<Exception> {
checkHeadersAndValidateSignature(
null,
testBody,
null,
testConfig,
)
}
assertEquals("signing key is required", exception.message)
}

@Test
fun `fails if in prod with signing key but signature is missing`() {
val testConfig =
ServeConfig(
Inngest("unit-test", env = "prod"),
signingKey = testKey,
)
assertFailsWith<InvalidSignatureHeaderException> {
checkHeadersAndValidateSignature(
null,
testBody,
null,
testConfig,
)
}
}

@Test
fun `checkHeadersAndValidateSignature succeeds if signature matches and timestamp is within a reasonable time`() {
val testConfig =
ServeConfig(
Inngest("unit-test", env = "prod"),
signingKey = testKey,
)

val now = Instant.now().epochSecond
val signature = signRequest(testBody, now, testKey)

assertDoesNotThrow {
checkHeadersAndValidateSignature(
"t=$now&s=$signature",
testBody,
null,
testConfig,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.inngest.CommHandler;
import com.inngest.CommResponse;
import com.inngest.signingkey.SignatureVerificationKt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -33,10 +34,14 @@ public ResponseEntity<String> put() {

@PostMapping()
public ResponseEntity<String> handleRequest(
@RequestHeader(name = "X-Inngest-Signature", required = false) String signature,
@RequestHeader(name = "X-Inngest-Server-Kind", required = false) String serverKind,
@RequestParam(name = "fnId") String functionId,
@RequestBody String body
) {
try {
SignatureVerificationKt.checkHeadersAndValidateSignature(signature, body, serverKind, commHandler.getConfig());

CommResponse response = commHandler.callFunction(functionId, body);

HttpHeaders headers = new HttpHeaders();
Expand Down

0 comments on commit 63e02f8

Please sign in to comment.