Skip to content

Commit 207de4d

Browse files
handle more complex validations (ie. stripe, slack)
1 parent cfa7e32 commit 207de4d

2 files changed

Lines changed: 189 additions & 49 deletions

File tree

readme.md

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ An [Otoroshi](https://github.com/MAIF/otoroshi) plugin that validates webhook pa
44

55
## How it works
66

7-
The plugin is provider-agnostic: the signature header, HMAC algorithm and prefix are all configurable.
7+
The plugin is provider-agnostic: the signature header, HMAC algorithm, prefix and signing payload template are all configurable.
88

99
The plugin:
1010

1111
1. Reads the raw request body.
12-
2. Computes `HMAC-<algorithm>(secret, rawBody)` using the configured secret and algorithm.
13-
3. Prepends the configured prefix to the hex-encoded hash to form the expected signature.
14-
4. Compares the result (constant-time, to prevent timing attacks) against the configured signature header.
15-
5. Forwards the request to your backend unchanged when the signature is valid.
16-
6. Returns **401 Unauthorized** when the signature is missing or invalid.
12+
2. Optionally resolves a timestamp (from a dedicated header or extracted from the signature header via regex).
13+
3. Builds the signing payload by applying the `signing_payload_template` (e.g. `{timestamp}.{body}` for Stripe, `v0:{timestamp}:{body}` for Slack, or plain `{body}` for most providers).
14+
4. Computes `HMAC-<algorithm>(secret, signingPayload)` using the configured secret and algorithm.
15+
5. Prepends the configured prefix to the hex-encoded hash to form the expected signature.
16+
6. Optionally extracts the actual signature from the header value using a regex (e.g. `v1=([^,]+)` for Stripe).
17+
7. Compares the result (constant-time, to prevent timing attacks) against the extracted or raw signature header value.
18+
8. Forwards the request to your backend unchanged when the signature is valid.
19+
9. Returns **401 Unauthorized** when the signature is missing, the timestamp cannot be resolved, or the signature is invalid.
1720

1821
## Create a route to receive GitHub webhooks
1922

@@ -81,21 +84,104 @@ $ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
8184
}'
8285
```
8386

87+
## Create a route to receive Stripe webhooks
88+
89+
Stripe signs the payload as `{timestamp}.{body}` and sends the signature as `t=<timestamp>,v1=<sig>` in the `Stripe-Signature` header. Both the timestamp and the signature must be extracted from that header with a regex.
90+
91+
```shell
92+
$ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
93+
-H "Content-type: application/json" \
94+
-u 'admin-api-apikey-id:admin-api-apikey-secret' \
95+
-d '{
96+
"name": "stripe-webhook-receiver",
97+
"frontend": {
98+
"domains": ["webhooks.oto.tools/stripe"]
99+
},
100+
"backend": {
101+
"targets": [{
102+
"hostname": "my-backend.example.com",
103+
"port": 443,
104+
"tls": true
105+
}]
106+
},
107+
"plugins": [
108+
{
109+
"enabled": true,
110+
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.plugins.webhook.WebhookPayloadValidator",
111+
"config": {
112+
"secret": "whsec_your-stripe-webhook-secret",
113+
"signature_header": "Stripe-Signature",
114+
"algorithm": "HmacSHA256",
115+
"prefix": "",
116+
"signing_payload_template": "{timestamp}.{body}",
117+
"timestamp_extraction_regex": "t=([^,]+)",
118+
"signature_extraction_regex": "v1=([^,]+)"
119+
}
120+
}
121+
]
122+
}'
123+
```
124+
125+
## Create a route to receive Slack webhooks
126+
127+
Slack signs the payload as `v0:{timestamp}:{body}` and sends the timestamp in a separate `X-Slack-Request-Timestamp` header. The signature header value is `v0=<sig>`.
128+
129+
```shell
130+
$ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
131+
-H "Content-type: application/json" \
132+
-u 'admin-api-apikey-id:admin-api-apikey-secret' \
133+
-d '{
134+
"name": "slack-webhook-receiver",
135+
"frontend": {
136+
"domains": ["webhooks.oto.tools/slack"]
137+
},
138+
"backend": {
139+
"targets": [{
140+
"hostname": "my-backend.example.com",
141+
"port": 443,
142+
"tls": true
143+
}]
144+
},
145+
"plugins": [
146+
{
147+
"enabled": true,
148+
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.plugins.webhook.WebhookPayloadValidator",
149+
"config": {
150+
"secret": "your-slack-signing-secret",
151+
"signature_header": "X-Slack-Signature",
152+
"algorithm": "HmacSHA256",
153+
"prefix": "v0=",
154+
"signing_payload_template": "v0:{timestamp}:{body}",
155+
"timestamp_header": "X-Slack-Request-Timestamp"
156+
}
157+
}
158+
]
159+
}'
160+
```
161+
84162
## Plugin configuration
85163

86-
| Field | Type | Required | Default | Description |
87-
|--------------------|----------|----------|--------------------------|-------------------------------------------------------------------------------------|
88-
| `secret` | `string` | yes || The HMAC secret shared with the webhook provider. |
89-
| `signature_header` | `string` | no | `X-Hub-Signature-256` | Name of the HTTP header that carries the signature. |
90-
| `algorithm` | `string` | no | `HmacSHA256` | Java HMAC algorithm name. Supported values: `HmacSHA256`, `HmacSHA512`, `HmacSHA384`, `HmacSHA1`. |
91-
| `prefix` | `string` | no | derived from `algorithm` | String prepended to the hex hash before comparison (e.g. `sha256=`). Defaults are derived automatically from the chosen algorithm. |
164+
| Field | Type | Required | Default | Description |
165+
|------------------------------|----------|----------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
166+
| `secret` | `string` | yes || The HMAC secret shared with the webhook provider. |
167+
| `signature_header` | `string` | no | `X-Hub-Signature-256` | Name of the HTTP header that carries the signature. |
168+
| `algorithm` | `string` | no | `HmacSHA256` | Java HMAC algorithm name. Supported values: `HmacSHA256`, `HmacSHA512`, `HmacSHA384`, `HmacSHA1`. |
169+
| `prefix` | `string` | no | derived from `algorithm` | String prepended to the hex hash before comparison (e.g. `sha256=`). Use `""` when the comparison value is raw hex (Stripe). |
170+
| `signing_payload_template` | `string` | no | `{body}` | Template for the HMAC input. Supports `{body}` (raw body) and `{timestamp}`. Examples: `{timestamp}.{body}` (Stripe), `v0:{timestamp}:{body}` (Slack). |
171+
| `timestamp_header` | `string` | no | `""` | Name of a separate HTTP header containing the timestamp (e.g. `X-Slack-Request-Timestamp` for Slack). |
172+
| `timestamp_extraction_regex` | `string` | no | `""` | Regex with one capture group to extract the timestamp from the **signature header** value (e.g. `t=([^,]+)` for Stripe). |
173+
| `signature_extraction_regex` | `string` | no | `""` | Regex with one capture group to extract the actual signature from the **signature header** value (e.g. `v1=([^,]+)` for Stripe). When empty the full header value is used. |
92174

93175
```json
94176
{
95177
"secret": "your-webhook-secret",
96178
"signature_header": "X-Hub-Signature-256",
97179
"algorithm": "HmacSHA256",
98-
"prefix": "sha256="
180+
"prefix": "sha256=",
181+
"signing_payload_template": "{body}",
182+
"timestamp_header": "",
183+
"timestamp_extraction_regex": "",
184+
"signature_extraction_regex": ""
99185
}
100186
```
101187

@@ -113,7 +199,8 @@ $ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
113199
| Status | Body | Meaning |
114200
|--------|------------------------------------------------|---------|
115201
| forwarded to backend || Signature is valid, request is passed through unchanged. |
116-
| `401 Unauthorized` | `{ "error": "missing xxxx header" }` | The header was not present in the incoming request. |
202+
| `401 Unauthorized` | `{ "error": "missing xxxx header" }` | The signature header was not present in the incoming request. |
203+
| `401 Unauthorized` | `{ "error": "missing timestamp" }` | The timestamp could not be resolved (missing header or regex did not match). |
117204
| `401 Unauthorized` | `{ "error": "invalid signature" }` | The computed HMAC does not match the header value. |
118205
| `401 Unauthorized` | `{ "error": "webhook secret not configured" }` | The plugin `secret` field is empty. |
119206

src/main/scala/com/cloud/apim/otoroshi/plugins/webhook/validator.scala

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ import scala.concurrent.{ExecutionContext, Future}
1818
import scala.util.{Failure, Success, Try}
1919

2020
case class WebhookValidatorConfig(
21-
secret: String = "",
22-
signatureHeader: String = "X-Hub-Signature-256",
23-
algorithm: String = "HmacSHA256",
24-
prefix: String = "sha256=",
21+
secret: String = "",
22+
signatureHeader: String = "X-Hub-Signature-256",
23+
algorithm: String = "HmacSHA256",
24+
prefix: String = "sha256=",
25+
signingPayloadTemplate: String = "{body}",
26+
timestampHeader: String = "",
27+
timestampExtractionRegex: String = "",
28+
signatureExtractionRegex: String = "",
2529
) extends NgPluginConfig {
2630
def json: JsValue = WebhookValidatorConfig.format.writes(this)
2731
}
@@ -30,18 +34,26 @@ object WebhookValidatorConfig {
3034
val default: WebhookValidatorConfig = WebhookValidatorConfig()
3135
val format: Format[WebhookValidatorConfig] = new Format[WebhookValidatorConfig] {
3236
override def writes(o: WebhookValidatorConfig): JsValue = Json.obj(
33-
"secret" -> o.secret,
34-
"signature_header" -> o.signatureHeader,
35-
"algorithm" -> o.algorithm,
36-
"prefix" -> o.prefix,
37+
"secret" -> o.secret,
38+
"signature_header" -> o.signatureHeader,
39+
"algorithm" -> o.algorithm,
40+
"prefix" -> o.prefix,
41+
"signing_payload_template" -> o.signingPayloadTemplate,
42+
"timestamp_header" -> o.timestampHeader,
43+
"timestamp_extraction_regex"-> o.timestampExtractionRegex,
44+
"signature_extraction_regex"-> o.signatureExtractionRegex,
3745
)
3846
override def reads(json: JsValue): JsResult[WebhookValidatorConfig] = Try {
3947
val algo = json.select("algorithm").asOpt[String].getOrElse("HmacSHA256")
4048
WebhookValidatorConfig(
41-
secret = json.select("secret").asOpt[String].getOrElse(""),
42-
signatureHeader = json.select("signature_header").asOpt[String].getOrElse("X-Hub-Signature-256"),
43-
algorithm = algo,
44-
prefix = json.select("prefix").asOpt[String].getOrElse(WebhookValidatorConfig.defaultPrefix(algo)),
49+
secret = json.select("secret").asOpt[String].getOrElse(""),
50+
signatureHeader = json.select("signature_header").asOpt[String].getOrElse("X-Hub-Signature-256"),
51+
algorithm = algo,
52+
prefix = json.select("prefix").asOpt[String].getOrElse(WebhookValidatorConfig.defaultPrefix(algo)),
53+
signingPayloadTemplate = json.select("signing_payload_template").asOpt[String].getOrElse("{body}"),
54+
timestampHeader = json.select("timestamp_header").asOpt[String].getOrElse(""),
55+
timestampExtractionRegex = json.select("timestamp_extraction_regex").asOpt[String].getOrElse(""),
56+
signatureExtractionRegex = json.select("signature_extraction_regex").asOpt[String].getOrElse(""),
4557
)
4658
} match {
4759
case Failure(e) => JsError(e.getMessage)
@@ -58,7 +70,11 @@ object WebhookValidatorConfig {
5870
case _ => "sha256="
5971
}
6072

61-
val configFlow: Seq[String] = Seq("secret", "signature_header", "algorithm", "prefix")
73+
val configFlow: Seq[String] = Seq(
74+
"secret", "signature_header", "algorithm", "prefix",
75+
"signing_payload_template", "timestamp_header",
76+
"timestamp_extraction_regex", "signature_extraction_regex",
77+
)
6278
val configSchema: Option[JsObject] = Some(Json.obj(
6379
"secret" -> Json.obj("type" -> "password", "label" -> "Webhook Secret"),
6480
"signature_header" -> Json.obj("type" -> "string", "label" -> "Signature Header"),
@@ -74,7 +90,11 @@ object WebhookValidatorConfig {
7490
),
7591
),
7692
),
77-
"prefix" -> Json.obj("type" -> "string", "label" -> "Signature Prefix"),
93+
"prefix" -> Json.obj("type" -> "string", "label" -> "Signature Prefix"),
94+
"signing_payload_template" -> Json.obj("type" -> "string", "label" -> "Signing Payload Template (use {body} and {timestamp})"),
95+
"timestamp_header" -> Json.obj("type" -> "string", "label" -> "Timestamp Header (e.g. X-Slack-Request-Timestamp)"),
96+
"timestamp_extraction_regex" -> Json.obj("type" -> "string", "label" -> "Regex to extract timestamp from signature header (e.g. t=([^,]+))"),
97+
"signature_extraction_regex" -> Json.obj("type" -> "string", "label" -> "Regex to extract signature from signature header (e.g. v1=([^,]+))"),
7898
))
7999
}
80100

@@ -88,7 +108,7 @@ class WebhookPayloadValidator extends NgRequestTransformer {
88108
override def multiInstance: Boolean = true
89109
override def core: Boolean = false
90110
override def name: String = "Cloud APIM - Webhook Payload Validator"
91-
override def description: Option[String] = Some("This plugin validates webhook payloads by verifying an HMAC signature. The header name, algorithm and prefix are all configurable.")
111+
override def description: Option[String] = Some("This plugin validates webhook payloads by verifying an HMAC signature. The header name, algorithm, prefix and signing payload template are all configurable.")
92112
override def defaultConfigObject: Option[NgPluginConfig] = Some(WebhookValidatorConfig.default)
93113
override def noJsForm: Boolean = false
94114
override def configFlow: Seq[String] = WebhookValidatorConfig.configFlow
@@ -112,6 +132,14 @@ class WebhookPayloadValidator extends NgRequestTransformer {
112132
mac.doFinal(body.toArray).map(b => f"${b & 0xff}%02x").mkString
113133
}
114134

135+
private def buildSigningPayload(template: String, bodyBytes: ByteString, timestamp: String): ByteString = {
136+
if (template.isEmpty || template == "{body}") {
137+
bodyBytes
138+
} else {
139+
ByteString(template.replace("{timestamp}", timestamp).replace("{body}", bodyBytes.utf8String), "UTF-8")
140+
}
141+
}
142+
115143
override def transformRequest(ctx: NgTransformerRequestContext)(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, NgPluginHttpRequest]] = {
116144
val config = ctx.cachedConfig(internalName)(WebhookValidatorConfig.format).getOrElse(WebhookValidatorConfig.default)
117145
if (config.secret.isEmpty) {
@@ -122,26 +150,51 @@ class WebhookPayloadValidator extends NgRequestTransformer {
122150
case None =>
123151
if (logger.isDebugEnabled) logger.debug(s"[Webhook Validator] missing ${config.signatureHeader} header")
124152
Left(Results.Unauthorized(Json.obj("error" -> s"missing ${config.signatureHeader} header"))).vfuture
125-
case Some(receivedSignature) =>
126-
ctx.otoroshiRequest.body.runFold(ByteString.empty)(_ ++ _).map { bodyBytes =>
127-
val computedHash = computeHmac(config.algorithm, config.secret, bodyBytes)
128-
val expectedSignature = s"${config.prefix}$computedHash"
129-
130-
if (logger.isDebugEnabled) {
131-
logger.debug(s"[Webhook Validator] expected : $expectedSignature")
132-
logger.debug(s"[Webhook Validator] received : $receivedSignature")
133-
}
134-
// Constant-time comparison to prevent timing-attack side channels
135-
val expected = expectedSignature.getBytes("UTF-8")
136-
val received = receivedSignature.getBytes("UTF-8")
137-
138-
if (MessageDigest.isEqual(expected, received)) {
139-
// Re-emit the already-consumed body so downstream plugins / the backend still see it
140-
Right(ctx.otoroshiRequest.copy(body = Source.single(bodyBytes)))
141-
} else {
142-
logger.warn(s"[Webhook Validator] invalid signature for header ${config.signatureHeader}")
143-
Left(Results.Unauthorized(Json.obj("error" -> "invalid signature")))
144-
}
153+
case Some(rawSignatureHeader) =>
154+
// Extract actual signature value from the header (e.g. Stripe: "t=...,v1=<sig>")
155+
val receivedSignature: String =
156+
if (config.signatureExtractionRegex.nonEmpty)
157+
config.signatureExtractionRegex.r.findFirstMatchIn(rawSignatureHeader).map(_.group(1)).getOrElse(rawSignatureHeader)
158+
else
159+
rawSignatureHeader
160+
161+
// Resolve timestamp when the template needs it
162+
val needsTimestamp = config.signingPayloadTemplate.contains("{timestamp}")
163+
val timestampOpt: Option[String] =
164+
if (!needsTimestamp) Some("")
165+
else if (config.timestampHeader.nonEmpty)
166+
ctx.request.headers.get(config.timestampHeader)
167+
else if (config.timestampExtractionRegex.nonEmpty)
168+
config.timestampExtractionRegex.r.findFirstMatchIn(rawSignatureHeader).map(_.group(1))
169+
else Some("")
170+
171+
timestampOpt match {
172+
case None =>
173+
val source = if (config.timestampHeader.nonEmpty) s"header '${config.timestampHeader}'" else s"signature header via regex '${config.timestampExtractionRegex}'"
174+
if (logger.isDebugEnabled) logger.debug(s"[Webhook Validator] missing timestamp from $source")
175+
Left(Results.Unauthorized(Json.obj("error" -> s"missing timestamp"))).vfuture
176+
case Some(timestamp) =>
177+
ctx.otoroshiRequest.body.runFold(ByteString.empty)(_ ++ _).map { bodyBytes =>
178+
val signingPayload = buildSigningPayload(config.signingPayloadTemplate, bodyBytes, timestamp)
179+
val computedHash = computeHmac(config.algorithm, config.secret, signingPayload)
180+
val expectedSignature = s"${config.prefix}$computedHash"
181+
182+
if (logger.isDebugEnabled) {
183+
logger.debug(s"[Webhook Validator] expected : $expectedSignature")
184+
logger.debug(s"[Webhook Validator] received : $receivedSignature")
185+
}
186+
// Constant-time comparison to prevent timing-attack side channels
187+
val expected = expectedSignature.getBytes("UTF-8")
188+
val received = receivedSignature.getBytes("UTF-8")
189+
190+
if (MessageDigest.isEqual(expected, received)) {
191+
// Re-emit the already-consumed body so downstream plugins / the backend still see it
192+
Right(ctx.otoroshiRequest.copy(body = Source.single(bodyBytes)))
193+
} else {
194+
logger.warn(s"[Webhook Validator] invalid signature for header ${config.signatureHeader}")
195+
Left(Results.Unauthorized(Json.obj("error" -> "invalid signature")))
196+
}
197+
}
145198
}
146199
}
147200
}

0 commit comments

Comments
 (0)