diff --git a/CHANGELOG.md b/CHANGELOG.md index da4aa7f33f..c10a7fca62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/modules/components/pages/inputs/gateway.adoc b/docs/modules/components/pages/inputs/gateway.adoc index a1d55e40c5..c977f15f1f 100644 --- a/docs/modules/components/pages/inputs/gateway.adoc +++ b/docs/modules/components/pages/inputs/gateway.adoc @@ -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 @@ -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` @@ -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]. diff --git a/internal/gateway/hmac.go b/internal/gateway/hmac.go new file mode 100644 index 0000000000..2ac3c495f1 --- /dev/null +++ b/internal/gateway/hmac.go @@ -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) + 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) + }) +} diff --git a/internal/gateway/hmac_test.go b/internal/gateway/hmac_test.go new file mode 100644 index 0000000000..bd25d085ac --- /dev/null +++ b/internal/gateway/hmac_test.go @@ -0,0 +1,554 @@ +// 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_test + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "errors" + "hash" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/redpanda-data/benthos/v4/public/service" + "github.com/redpanda-data/connect/v4/internal/gateway" + "github.com/redpanda-data/connect/v4/internal/license" +) + +// defaultTestMaxBodySize is a generous max body size used by tests that are +// not exercising the body-size cap itself. +const defaultTestMaxBodySize = 4 * 1024 * 1024 + +// signHex computes the hex-encoded HMAC of body under secret using newHash. +func signHex(t *testing.T, newHash func() hash.Hash, secret, body string) string { + t.Helper() + mac := hmac.New(newHash, []byte(secret)) + _, err := mac.Write([]byte(body)) + require.NoError(t, err) + return hex.EncodeToString(mac.Sum(nil)) +} + +// failingReader is an io.Reader that always errors. It is used to prove that +// requests rejected on signature length are never read for a body, since +// that rejection must happen before any body buffering occurs. +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { + return 0, errors.New("body should not have been read") +} + +// hmacNextHandlerSpy is an http.Handler that records whether it was called +// and captures the full request body as observed by the downstream handler. +type hmacNextHandlerSpy struct { + called bool + bodyRead []byte +} + +func (s *hmacNextHandlerSpy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.called = true + b, err := io.ReadAll(r.Body) + if err == nil { + s.bodyRead = b + } + w.WriteHeader(http.StatusOK) +} + +func TestHMACMiddlewareConstructor(t *testing.T) { + for _, test := range []struct { + name string + secret string + header string + algorithm string + maxBodySize int + injectLicense bool + errContains string + }{ + { + name: "valid sha256", + secret: "topsecret", + header: "X-Signature", + algorithm: "sha256", + maxBodySize: defaultTestMaxBodySize, + injectLicense: true, + }, + { + name: "valid sha512", + secret: "topsecret", + header: "X-Signature", + algorithm: "sha512", + maxBodySize: defaultTestMaxBodySize, + injectLicense: true, + }, + { + name: "empty secret", + secret: "", + header: "X-Signature", + algorithm: "sha512", + maxBodySize: defaultTestMaxBodySize, + injectLicense: true, + errContains: "non-empty secret", + }, + { + name: "empty header", + secret: "topsecret", + header: "", + algorithm: "sha512", + maxBodySize: defaultTestMaxBodySize, + injectLicense: true, + errContains: "non-empty header name", + }, + { + name: "unsupported algorithm", + secret: "topsecret", + header: "X-Signature", + algorithm: "md5", + maxBodySize: defaultTestMaxBodySize, + injectLicense: true, + errContains: "is not supported", + }, + { + name: "missing enterprise license", + secret: "topsecret", + header: "X-Signature", + algorithm: "sha512", + maxBodySize: defaultTestMaxBodySize, + injectLicense: false, + errContains: "requires a valid license", + }, + { + name: "zero max body size", + secret: "topsecret", + header: "X-Signature", + algorithm: "sha512", + maxBodySize: 0, + injectLicense: true, + errContains: "positive max body size", + }, + { + name: "negative max body size", + secret: "topsecret", + header: "X-Signature", + algorithm: "sha512", + maxBodySize: -1, + injectLicense: true, + errContains: "positive max body size", + }, + } { + t.Run(test.name, func(t *testing.T) { + mgr := service.MockResources() + if test.injectLicense { + license.InjectTestService(mgr) + } + + m, err := gateway.NewHMACMiddleware(mgr, gateway.HMACConfig{ + Secret: test.secret, + Header: test.header, + Algorithm: test.algorithm, + MaxBodySize: test.maxBodySize, + }) + if test.errContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.errContains) + assert.Nil(t, m) + } else { + require.NoError(t, err) + assert.NotNil(t, m) + } + }) + } +} + +func newTestHMACMiddleware(t *testing.T, secret, headerName, algorithm string) *gateway.HMACMiddleware { + t.Helper() + return newTestHMACMiddlewareWithMaxBodySize(t, secret, headerName, algorithm, defaultTestMaxBodySize) +} + +func newTestHMACMiddlewareWithMaxBodySize(t *testing.T, secret, headerName, algorithm string, maxBodySize int) *gateway.HMACMiddleware { + t.Helper() + return newTestHMACMiddlewareFromConfig(t, gateway.HMACConfig{ + Secret: secret, + Header: headerName, + Algorithm: algorithm, + MaxBodySize: maxBodySize, + }) +} + +// newTestHMACMiddlewareWithPrefix builds a middleware with an explicit +// signature prefix, for tests exercising the prefix-stripping behavior of +// Wrap. +func newTestHMACMiddlewareWithPrefix(t *testing.T, secret, headerName, algorithm, prefix string) *gateway.HMACMiddleware { + t.Helper() + return newTestHMACMiddlewareFromConfig(t, gateway.HMACConfig{ + Secret: secret, + Header: headerName, + Algorithm: algorithm, + Prefix: prefix, + MaxBodySize: defaultTestMaxBodySize, + }) +} + +func newTestHMACMiddlewareFromConfig(t *testing.T, conf gateway.HMACConfig) *gateway.HMACMiddleware { + t.Helper() + mgr := service.MockResources() + license.InjectTestService(mgr) + m, err := gateway.NewHMACMiddleware(mgr, conf) + require.NoError(t, err) + return m +} + +func TestHMACMiddlewareWrapHappyPathSHA512(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const bodyStr = `{"hello":"world"}` + + m := newTestHMACMiddleware(t, secret, headerName, "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + req.Header.Set(headerName, signHex(t, sha512.New, secret, bodyStr)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, spy.called) + assert.Equal(t, bodyStr, string(spy.bodyRead)) +} + +func TestHMACMiddlewareWrapHappyPathSHA256(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const bodyStr = `{"hello":"world"}` + + m := newTestHMACMiddleware(t, secret, headerName, "sha256") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + req.Header.Set(headerName, signHex(t, sha256.New, secret, bodyStr)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, spy.called) + assert.Equal(t, bodyStr, string(spy.bodyRead)) +} + +func TestHMACMiddlewareWrapMissingSignatureHeader(t *testing.T) { + m := newTestHMACMiddleware(t, "topsecret", "X-Tfc-Task-Signature", "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"hello":"world"}`)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, spy.called) +} + +func TestHMACMiddlewareWrapNonHexSignature(t *testing.T) { + m := newTestHMACMiddleware(t, "topsecret", "X-Tfc-Task-Signature", "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"hello":"world"}`)) + req.Header.Set("X-Tfc-Task-Signature", "not-valid-hex!!") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, spy.called) +} + +func TestHMACMiddlewareWrapSignatureLengthMismatch(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const bodyStr = `{"hello":"world"}` + + // A correctly signed signature for bodyStr, used to derive a truncated + // (but still valid hex) signature below. + fullSig := signHex(t, sha512.New, secret, bodyStr) + + for _, test := range []struct { + name string + signature string + }{ + { + // Valid hex, correctly computed against the right secret and + // body, but cut in half so its decoded length no longer matches + // the sha512 MAC size. + name: "truncated correctly-signed signature", + signature: fullSig[:len(fullSig)/2], + }, + { + // Valid hex, far shorter than any supported MAC size. + name: "short valid hex", + signature: "deadbeef", + }, + } { + t.Run(test.name, func(t *testing.T) { + m := newTestHMACMiddleware(t, secret, headerName, "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + // The body is a failingReader: if the middleware attempted to + // read it before rejecting on length, the test would fail with + // a 400 or an unexpected error path instead of a clean 401. + req := httptest.NewRequest(http.MethodPost, "/test", failingReader{}) + req.Header.Set(headerName, test.signature) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, spy.called) + }) + } +} + +func TestHMACMiddlewareWrapWrongSignature(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const bodyStr = `{"hello":"world"}` + + m := newTestHMACMiddleware(t, secret, headerName, "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + // Valid hex, but signs a different body than what's sent. + req.Header.Set(headerName, signHex(t, sha512.New, secret, "not the real body")) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, spy.called) +} + +func TestHMACMiddlewareWrapWrongSecret(t *testing.T) { + const headerName = "X-Tfc-Task-Signature" + const bodyStr = `{"hello":"world"}` + + m := newTestHMACMiddleware(t, "topsecret", headerName, "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + req.Header.Set(headerName, signHex(t, sha512.New, "wrong-secret", bodyStr)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, spy.called) +} + +func TestHMACMiddlewareWrapEmptyBody(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + + m := newTestHMACMiddleware(t, secret, headerName, "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("")) + req.Header.Set(headerName, signHex(t, sha512.New, secret, "")) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, spy.called) + assert.Empty(t, spy.bodyRead) +} + +func TestHMACMiddlewareWrapNilMiddleware(t *testing.T) { + var m *gateway.HMACMiddleware + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"hello":"world"}`)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, spy.called) +} + +func TestHMACMiddlewareWrapBodyExceedsMaxSize(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const maxBodySize = 16 + bodyStr := strings.Repeat("a", 64) + + m := newTestHMACMiddlewareWithMaxBodySize(t, secret, headerName, "sha512", maxBodySize) + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + // Correctly signed, but the body itself is larger than the configured cap. + req.Header.Set(headerName, signHex(t, sha512.New, secret, bodyStr)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + assert.False(t, spy.called) +} + +func TestHMACMiddlewareWrapBodyAtMaxSizeIsAllowed(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const maxBodySize = 16 + bodyStr := strings.Repeat("a", maxBodySize) + + m := newTestHMACMiddlewareWithMaxBodySize(t, secret, headerName, "sha512", maxBodySize) + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + req.Header.Set(headerName, signHex(t, sha512.New, secret, bodyStr)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + // http.MaxBytesReader permits reading exactly n bytes; only bodies + // strictly larger than the cap are rejected. + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, spy.called) + assert.Equal(t, bodyStr, string(spy.bodyRead)) +} + +func TestHMACMiddlewareWrapHeaderLookupIsCaseInsensitive(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Tfc-Task-Signature" + const bodyStr = `{"hello":"world"}` + + m := newTestHMACMiddleware(t, secret, headerName, "sha512") + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + // Set the header using a different casing than the configured header + // name; net/http canonicalizes header names so this must still match. + req.Header.Set("x-tfc-task-signature", signHex(t, sha512.New, secret, bodyStr)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, spy.called) + assert.Equal(t, bodyStr, string(spy.bodyRead)) +} + +func TestHMACMiddlewareWrapPrefix(t *testing.T) { + const secret = "topsecret" + const headerName = "X-Hub-Signature-256" + const bodyStr = `{"hello":"world"}` + + correctSig := signHex(t, sha256.New, secret, bodyStr) + + for _, test := range []struct { + name string + configuredPrefix string + headerValue string + wantStatus int + }{ + { + // GitHub/Meta-style: the configured prefix matches the one sent, + // so it is stripped and the remaining hex verifies correctly. + name: "prefix configured and header matches", + configuredPrefix: "sha256=", + headerValue: "sha256=" + correctSig, + wantStatus: http.StatusOK, + }, + { + // A correctly-signed, bare-hex signature is rejected when a + // prefix is configured but the header does not carry it. + name: "prefix configured but header omits it", + configuredPrefix: "sha256=", + headerValue: correctSig, + wantStatus: http.StatusUnauthorized, + }, + { + // With no prefix configured, CutPrefix trivially matches and the + // full header value (including the literal "sha256=") is passed + // to hex decoding, which fails since it is not valid hex. + name: "no prefix configured but header includes one", + configuredPrefix: "", + headerValue: "sha256=" + correctSig, + wantStatus: http.StatusUnauthorized, + }, + { + // The header carries a different fixed prefix than the one + // configured, so CutPrefix does not match. + name: "wrong prefix sent", + configuredPrefix: "sha256=", + headerValue: "sha512=" + correctSig, + wantStatus: http.StatusUnauthorized, + }, + { + // The header value is exactly the configured prefix, leaving an + // empty signature after stripping; this is valid hex (zero + // bytes) but fails the MAC size check. + name: "header equals prefix exactly", + configuredPrefix: "sha256=", + headerValue: "sha256=", + wantStatus: http.StatusUnauthorized, + }, + } { + t.Run(test.name, func(t *testing.T) { + m := newTestHMACMiddlewareWithPrefix(t, secret, headerName, "sha256", test.configuredPrefix) + + spy := &hmacNextHandlerSpy{} + handler := m.Wrap(spy) + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(bodyStr)) + req.Header.Set(headerName, test.headerValue) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, test.wantStatus, rec.Code) + if test.wantStatus == http.StatusOK { + assert.True(t, spy.called) + assert.Equal(t, bodyStr, string(spy.bodyRead)) + } else { + assert.False(t, spy.called) + } + }) + } +} diff --git a/internal/gateway/jwt_validator.go b/internal/gateway/jwt_validator.go index b227963138..fb198596aa 100644 --- a/internal/gateway/jwt_validator.go +++ b/internal/gateway/jwt_validator.go @@ -37,6 +37,13 @@ const ( rpEnvJWTOrgID = "REDPANDA_CLOUD_GATEWAY_JWT_ORGANIZATION_ID" ) +// PlatformJWTConfigured reports whether the platform-managed Redpanda Cloud +// JWT/RBAC authentication is configured for this environment, via the +// issuer URL environment variable. +func PlatformJWTConfigured() bool { + return os.Getenv(rpEnvJWTIssuer) != "" +} + // jwtValidator contains the JWT validation logic and is technology-agnostic. type jwtValidator struct { orgID string diff --git a/internal/impl/gateway/input.go b/internal/impl/gateway/input.go index cb5e36ca6e..6567195eec 100644 --- a/internal/impl/gateway/input.go +++ b/internal/impl/gateway/input.go @@ -43,8 +43,20 @@ const ( hsiFieldResponseStatus = "status" hsiFieldResponseHeaders = "headers" hsiFieldResponseExtractMetadata = "metadata_headers" + hsiFieldAuth = "auth" + hsiFieldHMAC = "hmac" + hsiFieldHMACSecret = "secret" + hsiFieldHMACHeader = "header" + hsiFieldHMACPrefix = "prefix" + hsiFieldHMACAlgorithm = "algorithm" + hsiFieldHMACMaxBodySize = "max_body_size" ) +// defaultHMACMaxBodySize caps the pre-authentication request body buffering +// performed by HMAC signature verification, which reads the full body before +// a caller has proven ownership of the shared secret. +const defaultHMACMaxBodySize = 4 * 1024 * 1024 // 4MB + // Gateway HTTP authorization permission const gatewayPermission authz.PermissionName = "dataplane_pipeline_gateway_invoke" @@ -52,12 +64,31 @@ type hsiConfig struct { Path string RateLimit string Response hsiResponseConfig + Auth authConfig // Set via environment variables Address string CORS gateway.CORSConfig } +// authConfig holds the set of supported request authentication mechanisms +// that, when configured, replace the platform-managed (Redpanda Cloud +// JWT/RBAC) authentication for this endpoint. Exactly one mechanism may be +// set at a time. HMAC is the only mechanism supported today; additional +// mechanisms should be added here as further fields alongside HMAC. +type authConfig struct { + HMAC hmacConfig +} + +type hmacConfig struct { + Enabled bool + Secret string + Header string + Prefix string + Algorithm string + MaxBodySize int +} + type hsiResponseConfig struct { Status *service.InterpolatedString Headers map[string]*service.InterpolatedString @@ -74,6 +105,43 @@ func hsiConfigFromParsed(pConf *service.ParsedConfig) (conf hsiConfig, err error if conf.Response, err = hsiResponseConfigFromParsed(pConf.Namespace(hsiFieldResponse)); err != nil { return } + if pConf.Contains(hsiFieldAuth) { + if conf.Auth, err = hsiAuthConfigFromParsed(pConf.Namespace(hsiFieldAuth)); err != nil { + return + } + } + return +} + +// hsiAuthConfigFromParsed parses the auth block. With a single supported +// mechanism (HMAC) "at most one mechanism set" holds trivially; if a second +// mechanism is added here, add a check that rejects setting more than one. +func hsiAuthConfigFromParsed(pConf *service.ParsedConfig) (conf authConfig, err error) { + if pConf.Contains(hsiFieldHMAC) { + if conf.HMAC, err = hsiHMACConfigFromParsed(pConf.Namespace(hsiFieldHMAC)); err != nil { + return + } + conf.HMAC.Enabled = true + } + return +} + +func hsiHMACConfigFromParsed(pConf *service.ParsedConfig) (conf hmacConfig, err error) { + if conf.Secret, err = pConf.FieldString(hsiFieldHMACSecret); err != nil { + return + } + if conf.Header, err = pConf.FieldString(hsiFieldHMACHeader); err != nil { + return + } + if conf.Prefix, err = pConf.FieldString(hsiFieldHMACPrefix); err != nil { + return + } + if conf.Algorithm, err = pConf.FieldString(hsiFieldHMACAlgorithm); err != nil { + return + } + if conf.MaxBodySize, err = pConf.FieldInt(hsiFieldHMACMaxBodySize); err != nil { + return + } return } @@ -134,7 +202,15 @@ This input adds the following metadata fields to each message: - All cookies `+"```"+` -You can access these metadata fields using xref:configuration:interpolation.adoc#bloblang-queries[function interpolation].`). +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( service.NewStringField(hsiFieldPath). Description("The endpoint path to listen for data delivery requests."). @@ -157,6 +233,45 @@ You can access these metadata fields using xref:configuration:interpolation.adoc service.NewMetadataFilterField(hsiFieldResponseExtractMetadata). Description("Specify criteria for which metadata values are added to the response as headers."), ), + service.NewObjectField(hsiFieldAuth, + service.NewObjectField(hsiFieldHMAC, + service.NewStringField(hsiFieldHMACSecret). + Description("The shared secret key used to compute and verify the HMAC signature."). + Secret(). + LintRule(`root = if this == "" { [ "a non-empty secret is required" ] }`), + service.NewStringField(hsiFieldHMACHeader). + Description("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."). + ShortDescription("The name of the request header containing the hex-encoded HMAC signature of the request body."). + Examples("X-Tfc-Task-Signature", "X-Hub-Signature-256"), + service.NewStringField(hsiFieldHMACPrefix). + Description("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."). + ShortDescription("An optional prefix stripped from the signature header value before hex decoding."). + Examples("sha256="). + Default(""), + service.NewStringAnnotatedEnumField(hsiFieldHMACAlgorithm, map[string]string{ + "sha256": "Verify signatures computed with HMAC-SHA256.", + "sha512": "Verify signatures computed with HMAC-SHA512.", + }). + Description("The hash algorithm used to compute the HMAC signature."). + Default("sha512"), + service.NewIntField(hsiFieldHMACMaxBodySize). + Description("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."). + Default(defaultHMACMaxBodySize). + Advanced(). + LintRule(`root = if this <= 0 { [ "max_body_size must be greater than zero" ] }`), + ). + Description(` +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.`). + Optional(), + ). + Description(` +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.`). + Optional(). + Advanced(), netutil.ListenerConfigSpec(). Description("Customize messages returned via xref:guides:sync_responses.adoc[synchronous responses]."). ShortDescription("Customize messages returned via synchronous responses."). @@ -191,6 +306,7 @@ type Input struct { rpJWTValidator *gateway.RPJWTMiddleware authzPolicy *gateway.FileWatchingAuthzResourcePolicy + hmacMiddleware *gateway.HMACMiddleware batches chan batchAndAck @@ -215,30 +331,49 @@ func InputFromParsed(pConf *service.ParsedConfig, mgr *service.Resources) (*Inpu mgr: mgr, batches: make(chan batchAndAck), } - if h.rpJWTValidator, err = gateway.NewRPJWTMiddleware(mgr); err != nil { - return nil, err - } - if authzConf, ok := gateway.ManagerAuthzConfig(mgr); ok { - errorCallback := func(err error) { - mgr.Logger().With("error", err).Error("Authorization policy error") + if h.conf.Auth.HMAC.Enabled { + if h.hmacMiddleware, err = gateway.NewHMACMiddleware(mgr, gateway.HMACConfig{ + Secret: h.conf.Auth.HMAC.Secret, + Header: h.conf.Auth.HMAC.Header, + Algorithm: h.conf.Auth.HMAC.Algorithm, + Prefix: h.conf.Auth.HMAC.Prefix, + MaxBodySize: h.conf.Auth.HMAC.MaxBodySize, + }); err != nil { + return nil, err } - if authzConf.PolicyEndpoint != "" { - h.authzPolicy, err = gateway.NewEndpointWatchingAuthzResourcePolicy( - authzConf.ResourceName, - authzConf.PolicyEndpoint, - []authz.PermissionName{gatewayPermission}, - errorCallback, - ) - } else if authzConf.PolicyFile != "" { - h.authzPolicy, err = gateway.NewFileWatchingAuthzResourcePolicy( - authzConf.ResourceName, - authzConf.PolicyFile, - []authz.PermissionName{gatewayPermission}, - errorCallback, - ) + if gateway.PlatformJWTConfigured() { + mgr.Logger().Warn("The configured auth.hmac mechanism replaces the platform-managed JWT/RBAC authentication for this endpoint.") } - if err != nil { - return nil, fmt.Errorf("initialize authorization policy: %w", err) + } else { + // The JWT validator and authorization policy are only used by + // createHandler in the non-HMAC branch; skip constructing them + // entirely in HMAC mode, since the authz policy setup opens a live + // file watch or gRPC stream that would otherwise sit unused. + if h.rpJWTValidator, err = gateway.NewRPJWTMiddleware(mgr); err != nil { + return nil, err + } + if authzConf, ok := gateway.ManagerAuthzConfig(mgr); ok { + errorCallback := func(err error) { + mgr.Logger().With("error", err).Error("Authorization policy error") + } + if authzConf.PolicyEndpoint != "" { + h.authzPolicy, err = gateway.NewEndpointWatchingAuthzResourcePolicy( + authzConf.ResourceName, + authzConf.PolicyEndpoint, + []authz.PermissionName{gatewayPermission}, + errorCallback, + ) + } else if authzConf.PolicyFile != "" { + h.authzPolicy, err = gateway.NewFileWatchingAuthzResourcePolicy( + authzConf.ResourceName, + authzConf.PolicyFile, + []authz.PermissionName{gatewayPermission}, + errorCallback, + ) + } + if err != nil { + return nil, fmt.Errorf("initialize authorization policy: %w", err) + } } } @@ -260,10 +395,18 @@ func InputFromParsed(pConf *service.ParsedConfig, mgr *service.Resources) (*Inpu func (ri *Input) createHandler() (h http.Handler) { h = http.HandlerFunc(ri.deliverHandler) h = gzipHandler(h) - if ri.authzPolicy != nil { - h = gateway.AuthzMiddleware(ri.authzPolicy, gatewayPermission, h) + // Exactly one auth mechanism applies to a given endpoint: any mechanism + // configured under `auth` replaces the JWT/RBAC authentication chain + // entirely. Additional mechanisms should be added as further cases here. + switch { + case ri.hmacMiddleware != nil: + h = ri.hmacMiddleware.Wrap(h) + default: + if ri.authzPolicy != nil { + h = gateway.AuthzMiddleware(ri.authzPolicy, gatewayPermission, h) + } + h = ri.rpJWTValidator.Wrap(h) } - h = ri.rpJWTValidator.Wrap(h) h = ri.conf.CORS.WrapHandler(h) return } diff --git a/internal/impl/gateway/input_hmac_test.go b/internal/impl/gateway/input_hmac_test.go new file mode 100644 index 0000000000..de3077580f --- /dev/null +++ b/internal/impl/gateway/input_hmac_test.go @@ -0,0 +1,438 @@ +// 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_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/redpanda-data/benthos/v4/public/service" + + "github.com/redpanda-data/connect/v4/internal/impl/gateway" + "github.com/redpanda-data/connect/v4/internal/license" +) + +func sha512HexSignature(t *testing.T, secret, body string) string { + t.Helper() + mac := hmac.New(sha512.New, []byte(secret)) + _, err := mac.Write([]byte(body)) + require.NoError(t, err) + return hex.EncodeToString(mac.Sum(nil)) +} + +func sha256HexSignature(t *testing.T, secret, body string) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(secret)) + _, err := mac.Write([]byte(body)) + require.NoError(t, err) + return hex.EncodeToString(mac.Sum(nil)) +} + +// drainOneBatch reads a single batch from the input and acknowledges it with +// no error, in a background goroutine, for the duration of tCtx. +func drainOneBatch(t *testing.T, tCtx context.Context, h *gateway.Input) { + t.Helper() + go func() { + batch, aFn, err := h.ReadBatch(tCtx) + if err != nil { + return + } + _ = aFn(tCtx, nil) + _ = batch + }() +} + +func TestGatewayInputHMACConfigEndToEnd(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + + tCtx, done := context.WithTimeout(t.Context(), 30*time.Second) + defer done() + + const secret = "topsecret" + + pConf, err := gateway.InputSpec().ParseYAML(` +path: /testpost +auth: + hmac: + secret: topsecret + header: X-Tfc-Task-Signature + algorithm: sha512 +`, nil) + require.NoError(t, err) + + mgr := service.MockResources() + license.InjectTestService(mgr) + + h, err := gateway.InputFromParsed(pConf, mgr) + require.NoError(t, err) + + router := mux.NewRouter() + require.NoError(t, h.RegisterCustomMux(router)) + + server := httptest.NewServer(router) + defer server.Close() + + t.Run("signed request passes", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + drainOneBatch(t, tCtx, h) + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Tfc-Task-Signature", sha512HexSignature(t, secret, bodyStr)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + }) + + t.Run("unsigned request is rejected", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + + res, err := http.Post(server.URL+"/testpost", "application/octet-stream", strings.NewReader(bodyStr)) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + + t.Run("bad signature request is rejected", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Tfc-Task-Signature", sha512HexSignature(t, "wrong-secret", bodyStr)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) +} + +// TestGatewayInputHMACPrefixEndToEnd exercises the GitHub-style +// `X-Hub-Signature-256: sha256=` convention end-to-end through the +// full input: a request signed with the configured prefix must pass, and +// the same request without the prefix must be rejected. +func TestGatewayInputHMACPrefixEndToEnd(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + + tCtx, done := context.WithTimeout(t.Context(), 30*time.Second) + defer done() + + const secret = "topsecret" + + pConf, err := gateway.InputSpec().ParseYAML(` +path: /testpost +auth: + hmac: + secret: topsecret + header: X-Hub-Signature-256 + algorithm: sha256 + prefix: "sha256=" +`, nil) + require.NoError(t, err) + + mgr := service.MockResources() + license.InjectTestService(mgr) + + h, err := gateway.InputFromParsed(pConf, mgr) + require.NoError(t, err) + + router := mux.NewRouter() + require.NoError(t, h.RegisterCustomMux(router)) + + server := httptest.NewServer(router) + defer server.Close() + + t.Run("prefixed signature passes", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + drainOneBatch(t, tCtx, h) + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Hub-Signature-256", "sha256="+sha256HexSignature(t, secret, bodyStr)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + }) + + t.Run("signature without prefix is rejected", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Hub-Signature-256", sha256HexSignature(t, secret, bodyStr)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) +} + +func TestGatewayInputHMACMaxBodySizeEndToEnd(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + + tCtx, done := context.WithTimeout(t.Context(), 30*time.Second) + defer done() + + const secret = "topsecret" + + pConf, err := gateway.InputSpec().ParseYAML(` +path: /testpost +auth: + hmac: + secret: topsecret + header: X-Tfc-Task-Signature + algorithm: sha512 + max_body_size: 32 +`, nil) + require.NoError(t, err) + + mgr := service.MockResources() + license.InjectTestService(mgr) + + h, err := gateway.InputFromParsed(pConf, mgr) + require.NoError(t, err) + + router := mux.NewRouter() + require.NoError(t, h.RegisterCustomMux(router)) + + server := httptest.NewServer(router) + defer server.Close() + + t.Run("oversized signed request is rejected", func(t *testing.T) { + bodyStr := strings.Repeat("a", 64) + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Tfc-Task-Signature", sha512HexSignature(t, secret, bodyStr)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusRequestEntityTooLarge, res.StatusCode) + }) + + t.Run("small signed request passes", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + drainOneBatch(t, tCtx, h) + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Tfc-Task-Signature", sha512HexSignature(t, secret, bodyStr)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + }) +} + +func TestGatewayInputWithoutHMACConfigBehavesAsBefore(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + + tCtx, done := context.WithTimeout(t.Context(), 30*time.Second) + defer done() + + pConf, err := gateway.InputSpec().ParseYAML(` +path: /testpost +`, nil) + require.NoError(t, err) + + // No license injected: absence of an hmac block must not require an + // enterprise license, matching pre-HMAC behavior. + h, err := gateway.InputFromParsed(pConf, service.MockResources()) + require.NoError(t, err) + + router := mux.NewRouter() + require.NoError(t, h.RegisterCustomMux(router)) + + server := httptest.NewServer(router) + defer server.Close() + + drainOneBatch(t, tCtx, h) + + // No JWT env vars are set, so the JWT middleware is a no-op and this + // plain, unsigned request must reach the handler as it did before HMAC + // support was added. + res, err := http.Post(server.URL+"/testpost", "application/octet-stream", strings.NewReader("plain body")) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) +} + +// TestGatewayInputHMACBypassesPlatformJWT verifies that when auth.hmac is +// enabled, it fully replaces the platform-managed JWT/RBAC authentication: +// a request signed with the HMAC secret and no Authorization header at all +// still succeeds even though the platform JWT env vars are configured, and +// an unsigned request is still rejected. +func TestGatewayInputHMACBypassesPlatformJWT(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + // JWKS fetching is lazy, so a fake issuer URL is fine here: the point of + // this test is that the JWT validator is never constructed at all when + // auth.hmac is enabled, so this URL is never dialed. + t.Setenv("REDPANDA_CLOUD_GATEWAY_JWT_ISSUER_URL", "https://127.0.0.1:1/") + t.Setenv("REDPANDA_CLOUD_GATEWAY_JWT_AUDIENCE", "test-audience") + t.Setenv("REDPANDA_CLOUD_GATEWAY_JWT_ORGANIZATION_ID", "test-org") + + tCtx, done := context.WithTimeout(t.Context(), 30*time.Second) + defer done() + + const secret = "topsecret" + + pConf, err := gateway.InputSpec().ParseYAML(` +path: /testpost +auth: + hmac: + secret: topsecret + header: X-Tfc-Task-Signature + algorithm: sha512 +`, nil) + require.NoError(t, err) + + mgr := service.MockResources() + license.InjectTestService(mgr) + + h, err := gateway.InputFromParsed(pConf, mgr) + require.NoError(t, err) + + router := mux.NewRouter() + require.NoError(t, h.RegisterCustomMux(router)) + + server := httptest.NewServer(router) + defer server.Close() + + t.Run("signed request without Authorization header passes", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + drainOneBatch(t, tCtx, h) + + req, err := http.NewRequestWithContext(tCtx, http.MethodPost, server.URL+"/testpost", strings.NewReader(bodyStr)) + require.NoError(t, err) + req.Header.Set("X-Tfc-Task-Signature", sha512HexSignature(t, secret, bodyStr)) + require.Empty(t, req.Header.Get("Authorization")) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + }) + + t.Run("unsigned request is still rejected", func(t *testing.T) { + const bodyStr = `{"hello":"world"}` + + res, err := http.Post(server.URL+"/testpost", "application/octet-stream", strings.NewReader(bodyStr)) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) +} + +func TestGatewayInputHMACInvalidAlgorithm(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + + // The enum field does not reject the bad value at ParseYAML time, only + // via linting, so parsing succeeds here. + pConf, err := gateway.InputSpec().ParseYAML(` +path: /testpost +auth: + hmac: + secret: topsecret + header: X-Tfc-Task-Signature + algorithm: md5 +`, nil) + require.NoError(t, err) + + // Confirm the linter flags the invalid enum value. + linter := service.NewEnvironment().NewComponentConfigLinter() + lints, err := linter.LintInputYAML([]byte(` +gateway: + path: /testpost + auth: + hmac: + secret: topsecret + header: X-Tfc-Task-Signature + algorithm: md5 +`)) + require.NoError(t, err) + require.Len(t, lints, 1) + assert.Contains(t, lints[0].Error(), "not a valid option") + + // Construction is the enforcement point: an unsupported algorithm must + // fail here even though ParseYAML let it through. + mgr := service.MockResources() + license.InjectTestService(mgr) + + _, err = gateway.InputFromParsed(pConf, mgr) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not supported") +} + +func TestGatewayInputHMACLintEmptySecret(t *testing.T) { + linter := service.NewEnvironment().NewComponentConfigLinter() + lints, err := linter.LintInputYAML([]byte(` +gateway: + path: /testpost + auth: + hmac: + secret: "" + header: X-Tfc-Task-Signature + algorithm: sha512 +`)) + require.NoError(t, err) + require.Len(t, lints, 1) + assert.Contains(t, lints[0].Error(), "a non-empty secret is required") +} + +func TestGatewayInputHMACLintNonPositiveMaxBodySize(t *testing.T) { + linter := service.NewEnvironment().NewComponentConfigLinter() + lints, err := linter.LintInputYAML([]byte(` +gateway: + path: /testpost + auth: + hmac: + secret: topsecret + header: X-Tfc-Task-Signature + algorithm: sha512 + max_body_size: 0 +`)) + require.NoError(t, err) + require.Len(t, lints, 1) + assert.Contains(t, lints[0].Error(), "max_body_size must be greater than zero") +} + +func TestGatewayInputHMACMissingHeaderFailsToParse(t *testing.T) { + t.Setenv("REDPANDA_CLOUD_GATEWAY_ADDRESS", "0.0.0.0:1234") + + // `header` has no default, so an hmac block without it must fail to + // parse rather than falling back to a default header name. + _, err := gateway.InputSpec().ParseYAML(` +path: /testpost +auth: + hmac: + secret: topsecret + algorithm: sha512 +`, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "header") +}