Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ Changelog

All notable changes to this project will be documented in this file.

## Unreleased

### Added

- gateway: Added an optional `auth.hmac` configuration block that authenticates incoming requests by verifying an HMAC signature (SHA-256 or SHA-512) of the raw request body against a shared secret, replacing the platform-managed authentication for the endpoint. This supports webhook-style callers that sign their payloads, such as Terraform Cloud Run Tasks, and callers that prefix the signature header value with a fixed value, such as GitHub and Meta, via the optional `prefix` field. ([@gousteris](https://github.com/gousteris), [#4652](https://github.com/redpanda-data/connect/pull/4652))

## 4.103.1 - 2026-07-31

### Added
Expand Down
107 changes: 107 additions & 0 deletions docs/modules/components/pages/inputs/gateway.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ input:
metadata_headers:
include_prefixes: []
include_patterns: []
auth:
hmac:
secret: "" # No default (required)
header: X-Tfc-Task-Signature # No default (required)
prefix: ""
algorithm: sha512
max_body_size: 4194304
tcp:
reuse_addr: false
reuse_port: false
Expand Down Expand Up @@ -100,6 +107,14 @@ This input adds the following metadata fields to each message:

You can access these metadata fields using xref:configuration:interpolation.adoc#bloblang-queries[function interpolation].

== Authentication

By default, requests to this endpoint are authenticated using the platform-managed Redpanda Cloud JWT/RBAC mechanism. Setting a mechanism under the `auth` field replaces this default authentication for the endpoint. `hmac` is the currently supported mechanism, which verifies a hex-encoded HMAC signature of the raw request body against a shared secret. This is intended for webhook-style callers that sign their payloads (for example, https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks[Terraform Cloud Run Tasks], which send a hex-encoded HMAC-SHA512 of the request body in a request header).

The `hmac` mechanism only authenticates the raw request body, and provides no replay protection: since there is no timestamp or nonce validation, anyone who captures a signed payload can resend it later. Request headers, query parameters, path parameters and cookies are not covered by the signature, even though they still become message metadata, so pipelines must not make authorization decisions based on that metadata alone.

Providers that prefix their signature header value with the algorithm name, such as GitHub's and Meta's `sha256=`, are supported via the `prefix` field, which strips the configured prefix before the remainder is hex-decoded.

== Fields

=== `path`
Expand Down Expand Up @@ -206,6 +221,98 @@ include_patterns:
- _timestamp_unix$
```

=== `auth`

Configures how incoming requests to this endpoint are authenticated. At most one authentication mechanism may be set. When a mechanism is set, it replaces the platform-managed (Redpanda Cloud JWT/RBAC) authentication for this endpoint. `hmac` is the currently supported mechanism.


*Type*: `object`


=== `auth.hmac`

Verifies incoming requests by computing an HMAC of the raw request body using `secret` and comparing it against the hex-encoded signature found in the request header named by `header`.

This is intended for webhook-style callers that sign their payloads, such as Terraform Cloud Run Tasks, which send a hex-encoded HMAC-SHA512 of the request body in a request header. Callers that prefix the signature with a fixed value, such as GitHub's and Meta's `sha256=`, are supported via `prefix`; the algorithm used for verification always comes from `algorithm` and is never inferred from the header.

Only the raw request body is covered by the signature, and there is no replay protection (no timestamp or nonce is validated), so a captured signed payload can be resent. Headers, query parameters, path parameters and cookies are not signed but are still exposed as message metadata, so avoid basing authorization decisions on them.


*Type*: `object`


=== `auth.hmac.secret`

The shared secret key used to compute and verify the HMAC signature.
[CAUTION]
====
This field contains sensitive information that usually shouldn't be added to a config directly, read our xref:configuration:secrets.adoc[secrets page for more info].
====



*Type*: `string`


=== `auth.hmac.header`

The name of the request header containing the HMAC signature of the request body. By default, the header value must be the bare hex-encoded signature; use `prefix` if the caller adds a fixed prefix such as `sha256=` before the signature.


*Type*: `string`


```yml
# Examples

header: X-Tfc-Task-Signature

header: X-Hub-Signature-256
```

=== `auth.hmac.prefix`

An optional exact-match prefix that the `header` value must begin with; it is stripped before the remaining value is hex-decoded. Some providers, such as GitHub and Meta, prefix their signature header value with the algorithm name (for example `sha256=`). This prefix is matched literally and does not influence which algorithm (`algorithm`) is used to verify the signature.


*Type*: `string`

*Default*: `""`

```yml
# Examples

prefix: sha256=
```

=== `auth.hmac.algorithm`

The hash algorithm used to compute the HMAC signature.


*Type*: `string`

*Default*: `"sha512"`

|===
| Option | Summary

| `sha256`
| Verify signatures computed with HMAC-SHA256.
| `sha512`
| Verify signatures computed with HMAC-SHA512.

|===

=== `auth.hmac.max_body_size`

The maximum size of the request body in bytes that will be read and buffered for signature verification. Requests with larger bodies are rejected with 413 Request Entity Too Large.


*Type*: `int`

*Default*: `4194304`

=== `tcp`

Customize messages returned via xref:guides:sync_responses.adoc[synchronous responses].
Expand Down
173 changes: 173 additions & 0 deletions internal/gateway/hmac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright 2026 Redpanda Data, Inc.
//
// Licensed as a Redpanda Enterprise file under the Redpanda Community
// License (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// https://github.com/redpanda-data/connect/blob/main/licenses/rcl.md

package gateway

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"maps"
"net/http"
"slices"
"strings"

"github.com/redpanda-data/benthos/v4/public/service"
"github.com/redpanda-data/connect/v4/internal/license"
)

// hmacHashConstructors maps supported algorithm names to their hash
// constructors.
var hmacHashConstructors = map[string]func() hash.Hash{
"sha256": sha256.New,
"sha512": sha512.New,
}

// HMACMiddleware authenticates incoming requests by verifying an HMAC
// signature of the raw request body against a shared secret. This is used
// to validate webhook-style callers, such as Terraform Cloud Run Tasks,
// that sign their payloads instead of presenting a bearer token. An
// optional fixed prefix on the signature header value (e.g. GitHub's
// `sha256=`) can be stripped prior to hex decoding; it has no bearing on
// which algorithm is used to verify the signature, which always comes from
// configuration.
type HMACMiddleware struct {
secret []byte
header string
prefix string
newHash func() hash.Hash
macSize int
logger *service.Logger
maxBodySize int64
}

// HMACConfig configures an HMACMiddleware.
type HMACConfig struct {
// Secret is the shared secret key used to compute and verify the HMAC
// signature.
Secret string
// Header is the name of the request header containing the HMAC
// signature of the request body.
Header string
// Algorithm is the hash algorithm used to compute the HMAC signature.
Algorithm string
// Prefix is an optional exact-match prefix that the signature header
// value must begin with; it is stripped before the remaining value is
// hex-decoded. It does not influence which algorithm is used for
// verification.
Prefix string
// MaxBodySize is the maximum size of the request body in bytes that
// will be read and buffered for signature verification.
MaxBodySize int
}

// NewHMACMiddleware creates a new HMAC signature validation middleware.
func NewHMACMiddleware(mgr *service.Resources, conf HMACConfig) (*HMACMiddleware, error) {
if err := license.CheckRunningEnterprise(mgr); err != nil {
return nil, fmt.Errorf("gateway hmac auth requires a valid license: %w", err)
}

if conf.Secret == "" {
return nil, errors.New("gateway HMAC authentication requires a non-empty secret")
}
if conf.Header == "" {
return nil, errors.New("gateway HMAC authentication requires a non-empty header name")
}
if conf.MaxBodySize <= 0 {
return nil, errors.New("gateway HMAC authentication requires a positive max body size")
}

newHash, exists := hmacHashConstructors[conf.Algorithm]
if !exists {
supported := strings.Join(slices.Sorted(maps.Keys(hmacHashConstructors)), ", ")
return nil, fmt.Errorf("gateway HMAC authentication algorithm %q is not supported, valid options are: %s", conf.Algorithm, supported)
}

return &HMACMiddleware{
secret: []byte(conf.Secret),
header: conf.Header,
prefix: conf.Prefix,
newHash: newHash,
macSize: newHash().Size(),
logger: mgr.Logger(),
maxBodySize: int64(conf.MaxBodySize),
}, nil
}

// Wrap a handler with HMAC signature validation. Any request that fails
// validation will be rejected and next will not be called.
func (m *HMACMiddleware) Wrap(next http.Handler) http.Handler {
if m == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
signatureHex := req.Header.Get(m.header)
if signatureHex == "" {
m.logger.With("header", m.header).Debug("Signature header not found")
http.Error(w, "signature header not found", http.StatusUnauthorized)
return
}

signatureHex, ok := strings.CutPrefix(signatureHex, m.prefix)
if !ok {
m.logger.Debug("Signature prefix mismatch")
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}

signature, err := hex.DecodeString(signatureHex)
if err != nil {
m.logger.With("error", err).Debug("Signature header is not valid hex")
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}

// Length is public information (no timing concern in comparing it),
// and rejecting early here avoids buffering the request body for a
// signature that can never match.
if len(signature) != m.macSize {
m.logger.With("expected_bytes", m.macSize, "actual_bytes", len(signature)).Debug("Signature length mismatch")
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}

req.Body = http.MaxBytesReader(w, req.Body, m.maxBodySize)

body, err := io.ReadAll(req.Body)
Comment thread
gousteris marked this conversation as resolved.
if err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
m.logger.With("error", err).Debug("Request body exceeded maximum size for signature verification")
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
m.logger.With("error", err).Error("Failed to read request body for signature verification")
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))

mac := hmac.New(m.newHash, m.secret)
mac.Write(body)
expected := mac.Sum(nil)

if !hmac.Equal(signature, expected) {
m.logger.Debug("HMAC signature verification failed")
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}

next.ServeHTTP(w, req)
})
}
Loading
Loading