Skip to content

Commit 4472875

Browse files
dtmeadowsDavid MeadowsTomerAberbach
authored andcommitted
feat(api): add webhook validation and parsing (#111)
* add webhook helpers * add webhook helpers * pr comments * Apply suggestions from code review Co-authored-by: Tomer Aberbach <tomer@aberba.ch> * refactor tests --------- Co-authored-by: David Meadows <dtmeadows@stainlessapi.com> Co-authored-by: Tomer Aberbach <tomer@aberba.ch>
1 parent d56a0d4 commit 4472875

7 files changed

Lines changed: 311 additions & 0 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ while (page != null) {
224224

225225
---
226226

227+
## Webhook Verification
228+
229+
We provide helper methods for verifying that a webhook request came from Orb, and not a malicious third party.
230+
231+
You can use `orb.webhooks().verifySignature(body, headers, secret?)` or `orb.webhooks().unwrap(body, headers, secret?)`,
232+
both of which will raise an error if the signature is invalid.
233+
234+
Note that the `body` parameter must be the raw JSON string sent from the server (do not parse it first).
235+
The `.unwrap()` method can parse this JSON for you.
236+
237+
---
238+
227239
## Error handling
228240

229241
This library throws exceptions in a single hierarchy for easy handling:

orb-java-core/src/main/kotlin/com/withorb/api/client/OrbClient.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,6 @@ interface OrbClient {
3434
fun subscriptions(): SubscriptionService
3535

3636
fun alerts(): AlertService
37+
38+
fun webhooks(): WebhookService
3739
}

orb-java-core/src/main/kotlin/com/withorb/api/client/OrbClientImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ constructor(
5959

6060
private val alerts: AlertService by lazy { AlertServiceImpl(clientOptionsWithUserAgent) }
6161

62+
private val webhooks: WebhookService by lazy { WebhookServiceImpl(clientOptions) }
63+
6264
override fun async(): OrbClientAsync = async
6365

6466
override fun topLevel(): TopLevelService = topLevel
@@ -86,4 +88,6 @@ constructor(
8688
override fun subscriptions(): SubscriptionService = subscriptions
8789

8890
override fun alerts(): AlertService = alerts
91+
92+
override fun webhooks(): WebhookService = webhooks
8993
}

orb-java-core/src/main/kotlin/com/withorb/api/core/Utils.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package com.withorb.api.core
44

5+
import com.withorb.api.core.http.Headers
56
import com.withorb.api.errors.OrbInvalidDataException
67
import java.util.Collections
78
import java.util.SortedMap
@@ -23,4 +24,8 @@ internal fun <K : Comparable<K>, V> SortedMap<K, V>.toImmutable(): SortedMap<K,
2324
if (isEmpty()) Collections.emptySortedMap()
2425
else Collections.unmodifiableSortedMap(toSortedMap(comparator()))
2526

27+
@JvmSynthetic
28+
internal fun Headers.getRequiredHeader(name: String): String =
29+
values(name).firstOrNull() ?: throw OrbInvalidDataException("Could not find $name header")
30+
2631
internal interface Enum
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// File generated from our OpenAPI spec by Stainless.
2+
3+
package com.withorb.api.services.blocking
4+
5+
import com.withorb.api.core.JsonValue
6+
import com.withorb.api.core.http.Headers
7+
8+
interface WebhookService {
9+
10+
fun unwrap(payload: String, headers: Headers, secret: String?): JsonValue
11+
12+
fun verifySignature(payload: String, headers: Headers, secret: String?)
13+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// File generated from our OpenAPI spec by Stainless.
2+
3+
package com.withorb.api.services.blocking
4+
5+
import com.fasterxml.jackson.core.JsonProcessingException
6+
import com.withorb.api.core.ClientOptions
7+
import com.withorb.api.core.JsonValue
8+
import com.withorb.api.core.getRequiredHeader
9+
import com.withorb.api.core.http.Headers
10+
import com.withorb.api.errors.OrbException
11+
import java.security.MessageDigest
12+
import java.time.Duration
13+
import java.time.Instant
14+
import java.time.LocalDateTime
15+
import java.time.ZoneOffset
16+
import java.util.*
17+
import javax.crypto.Mac
18+
import javax.crypto.spec.SecretKeySpec
19+
20+
private const val SIGNATURE_HEADER = "X-Orb-Signature"
21+
private const val TIMESTAMP_HEADER = "X-Orb-Timestamp"
22+
private const val MAX_TIMESTAMP_AGE_MINUTES = 5L
23+
private const val SIGNATURE_ALGORITHM = "HmacSHA256"
24+
private const val SIGNATURE_VERSION = "v1"
25+
private const val SIGNATURE_DELIMITER = " "
26+
private const val SIGNATURE_KEY_VALUE_DELIMITER = "="
27+
28+
class WebhookServiceImpl
29+
constructor(
30+
private val clientOptions: ClientOptions,
31+
) : WebhookService {
32+
@kotlin.ExperimentalStdlibApi
33+
override fun unwrap(payload: String, headers: Headers, secret: String?): JsonValue {
34+
verifySignature(payload, headers, secret)
35+
return try {
36+
clientOptions.jsonMapper.readValue(payload, JsonValue::class.java)
37+
} catch (e: JsonProcessingException) {
38+
throw OrbException("Invalid event payload", e)
39+
}
40+
}
41+
42+
@kotlin.ExperimentalStdlibApi
43+
override fun verifySignature(payload: String, headers: Headers, secret: String?) {
44+
val webhookSecret =
45+
secret
46+
?: clientOptions.webhookSecret
47+
?: throw OrbException(
48+
"The webhook secret must either be set using the env var, ORB_WEBHOOK_SECRET, on the client class, or passed to this method"
49+
)
50+
51+
val msgSignature = headers.getRequiredHeader(SIGNATURE_HEADER)
52+
val msgTimestamp = headers.getRequiredHeader(TIMESTAMP_HEADER)
53+
54+
val timestamp =
55+
try {
56+
LocalDateTime.parse(msgTimestamp).toInstant(ZoneOffset.UTC)
57+
} catch (e: RuntimeException) {
58+
throw OrbException("Invalid timestamp header", e)
59+
}
60+
val now = Instant.now(clientOptions.clock)
61+
62+
if (timestamp.isBefore(now.minus(Duration.ofMinutes(MAX_TIMESTAMP_AGE_MINUTES)))) {
63+
throw OrbException("Webhook timestamp too old")
64+
}
65+
if (timestamp.isAfter(now.plus(Duration.ofMinutes(MAX_TIMESTAMP_AGE_MINUTES)))) {
66+
throw OrbException("Webhook timestamp too new")
67+
}
68+
69+
val mac = Mac.getInstance(SIGNATURE_ALGORITHM)
70+
mac.init(SecretKeySpec(webhookSecret.toByteArray(Charsets.UTF_8), SIGNATURE_ALGORITHM))
71+
72+
val expectedSignature =
73+
mac.doFinal("v1:${msgTimestamp}:$payload".toByteArray(Charsets.UTF_8)).toHexString()
74+
75+
msgSignature.splitToSequence(SIGNATURE_DELIMITER).forEach {
76+
val parts = it.split(SIGNATURE_KEY_VALUE_DELIMITER)
77+
if (parts.size != 2) {
78+
return@forEach
79+
}
80+
81+
if (parts[0] != SIGNATURE_VERSION) {
82+
return@forEach
83+
}
84+
val actualSignature = parts[1].toByteArray(Charsets.UTF_8)
85+
86+
if (
87+
MessageDigest.isEqual(
88+
actualSignature,
89+
expectedSignature.toByteArray(Charsets.UTF_8)
90+
)
91+
) {
92+
return
93+
}
94+
}
95+
96+
throw OrbException("None of the given webhook signatures match the expected signature")
97+
}
98+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.withorb.api.services.blocking
2+
3+
import com.withorb.api.client.okhttp.OrbOkHttpClient
4+
import com.withorb.api.core.http.Headers
5+
import com.withorb.api.errors.OrbException
6+
import com.withorb.api.models.*
7+
import java.time.Clock
8+
import java.time.Instant
9+
import java.time.LocalDateTime
10+
import java.time.ZoneOffset
11+
import java.time.format.DateTimeFormatter
12+
import org.assertj.core.api.Assertions.*
13+
import org.junit.jupiter.api.Test
14+
15+
internal class WebhookServiceTest {
16+
companion object {
17+
const val API_KEY = "test-api-key"
18+
const val WEBHOOK_SECRET =
19+
"9d25de966891ab0bc18754faf8d83d0980b44ae330fcc130b41a6cf3daf1f391"
20+
const val SIGNATURE = "9d25de966891ab0bc18754faf8d83d0980b44ae330fcc130b41a6cf3daf1f391"
21+
const val SECRET = "c-UGKYdnhHh436B_sMouYAPUvXyWpzOdunZBV5dFSD8"
22+
const val WEBHOOK_TIMESTAMP = "2024-03-27T15:42:29.551"
23+
val FIXED_CLOCK = Clock.fixed(Instant.parse("2024-03-27T15:42:29+00:00"), ZoneOffset.UTC)
24+
val WEBHOOK_TIMESTAMP_INSTANT =
25+
LocalDateTime.parse(WEBHOOK_TIMESTAMP).toInstant(ZoneOffset.UTC)
26+
27+
const val PAYLOAD =
28+
"{\"id\": \"o4mmewpfNNTnjfZc\", \"created_at\": \"2024-03-27T15:42:29+00:00\", \"type\": \"resource_event.test\", \"properties\": {\"message\": \"A test webhook from Orb. Happy testing!\"}}"
29+
}
30+
31+
private fun buildClient() =
32+
OrbOkHttpClient.builder()
33+
.apiKey(API_KEY)
34+
.webhookSecret(WEBHOOK_SECRET)
35+
.clock(FIXED_CLOCK)
36+
.build()
37+
38+
private fun buildHeaders(signature: String = SIGNATURE, timestamp: String = WEBHOOK_TIMESTAMP) =
39+
Headers.builder()
40+
.put("X-Orb-Timestamp", timestamp)
41+
.put("X-Orb-Signature", "v1=$signature")
42+
.build()
43+
44+
@Test
45+
fun unwrap() {
46+
val client = buildClient()
47+
val headers = buildHeaders()
48+
val event = client.webhooks().unwrap(PAYLOAD, headers, SECRET)
49+
50+
assertThat(event).isNotNull()
51+
}
52+
53+
@Test
54+
fun verifySignatureHappyPath() {
55+
val client = buildClient()
56+
val headers = buildHeaders()
57+
58+
// Happy path
59+
assertThatCode { client.webhooks().verifySignature(PAYLOAD.trimIndent(), headers, SECRET) }
60+
.doesNotThrowAnyException()
61+
}
62+
63+
@Test
64+
fun verifySignatureFailsWhenTimestampIsTooOld() {
65+
val client = buildClient()
66+
// Timestamp too old
67+
assertThatThrownBy {
68+
client
69+
.webhooks()
70+
.verifySignature(
71+
PAYLOAD.trimIndent(),
72+
buildHeaders(
73+
timestamp =
74+
WEBHOOK_TIMESTAMP_INSTANT.minusSeconds(1000)
75+
.atOffset(ZoneOffset.UTC)
76+
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
77+
),
78+
SECRET
79+
)
80+
}
81+
.isInstanceOf(OrbException::class.java)
82+
.hasMessage("Webhook timestamp too old")
83+
}
84+
85+
@Test
86+
fun verifySignatureFailsWhenTimestampIsTooNew() {
87+
val client = buildClient()
88+
// Timestamp too new
89+
assertThatThrownBy {
90+
client
91+
.webhooks()
92+
.verifySignature(
93+
PAYLOAD.trimIndent(),
94+
buildHeaders(
95+
timestamp =
96+
WEBHOOK_TIMESTAMP_INSTANT.plusSeconds(1000)
97+
.atOffset(ZoneOffset.UTC)
98+
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
99+
),
100+
SECRET
101+
)
102+
}
103+
.isInstanceOf(OrbException::class.java)
104+
.hasMessage("Webhook timestamp too new")
105+
}
106+
107+
@Test
108+
fun verifySignatureFailsWithIncorrectSecret() {
109+
val client = buildClient()
110+
val headers = buildHeaders()
111+
// Invalid secrets
112+
assertThatThrownBy {
113+
client.webhooks().verifySignature(PAYLOAD.trimIndent(), headers, "invalid-secret")
114+
}
115+
.isInstanceOf(OrbException::class.java)
116+
.hasMessage("None of the given webhook signatures match the expected signature")
117+
118+
assertThatThrownBy {
119+
client.webhooks().verifySignature(PAYLOAD.trimIndent(), headers, "Zm9v")
120+
}
121+
.isInstanceOf(OrbException::class.java)
122+
.hasMessage("None of the given webhook signatures match the expected signature")
123+
}
124+
125+
fun verifySignatureSucceedsWithMultipleSignatures() {
126+
val client = buildClient()
127+
// Multiple signatures
128+
assertThatCode {
129+
client
130+
.webhooks()
131+
.verifySignature(
132+
PAYLOAD.trimIndent(),
133+
buildHeaders(signature = "$SIGNATURE v1=Zm9v"),
134+
SECRET
135+
)
136+
}
137+
.doesNotThrowAnyException()
138+
assertThatCode {
139+
client
140+
.webhooks()
141+
.verifySignature(
142+
PAYLOAD.trimIndent(),
143+
buildHeaders(signature = "v1=$SIGNATURE v2=$SIGNATURE"),
144+
SECRET
145+
)
146+
}
147+
.doesNotThrowAnyException()
148+
}
149+
150+
@Test
151+
fun verifySignatureFailsWithIncorrectSignatureVersion() {
152+
val client = buildClient()
153+
assertThatThrownBy {
154+
client
155+
.webhooks()
156+
.verifySignature(
157+
PAYLOAD.trimIndent(),
158+
buildHeaders(signature = "v2=$SIGNATURE"),
159+
SECRET
160+
)
161+
}
162+
.isInstanceOf(OrbException::class.java)
163+
.hasMessage("None of the given webhook signatures match the expected signature")
164+
}
165+
166+
@Test
167+
fun verifySignatureFailsWithMissingHeaders() {
168+
val client = buildClient()
169+
170+
val headers = Headers.builder().put("X-Orb-Signature", "v1=$SIGNATURE").build()
171+
assertThatThrownBy {
172+
client.webhooks().verifySignature(PAYLOAD.trimIndent(), headers, SECRET)
173+
}
174+
.isInstanceOf(OrbException::class.java)
175+
.hasMessage("Could not find X-Orb-Timestamp header")
176+
}
177+
}

0 commit comments

Comments
 (0)