http

package
v2.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 11, 2026 License: AGPL-3.0 Imports: 15 Imported by: 0

Documentation

Overview

Package http adapts requestsigning to inbound HTTP.

It is a routing.Middleware that verifies a request's signature before the handler runs and answers 401 when it does not check out — the inbound counterpart to httpclient.WithRequestSigning, over the same requestsigning.Verifier, so one configured scheme and one key source govern both directions.

keys, err := requestsigning.NewSecretKeySource(secretSource, "CALLBACK_KEY", "CALLBACK_KEY_PREVIOUS")
if err != nil {
	return err
}

verifier, err := requestsigning.NewVerifier(keys)
if err != nil {
	return err
}

mw, err := requestsigninghttp.NewMiddleware(verifier,
	requestsigninghttp.WithMetricsProvider(pillars.Metrics))
if err != nil {
	return err
}

routing.Post(router, "/callbacks/payments", handler, routing.WithMiddleware(mw))

Per route, not globally

Unlike the rate-limiting middleware, this one reads the body: a signature covers bytes, so the bytes have to be in hand before the handler runs. Installing it with Router.Use would make every upload route in the service pay for that. Install it on the endpoints that are actually signed.

The handler is then handed the buffered bytes rather than the socket, with GetBody set alongside. That is load-bearing. A handler that re-read the connection, or decoded and re-encoded before acting, would be acting on something other than what the signature covered — which is the single most common way a correct scheme is deployed incorrectly.

The body cap

Verification requires buffering, and an unauthenticated caller chooses how much. DefaultMaxBodySize caps it at one mebibyte; a body past the cap is rejected unverified, as a 401 rather than a 413, because that is what happened and because a distinct status would tell a prober where the cap sits.

WithMaxBodySize raises it for an endpoint that legitimately receives large signed payloads. There is no unlimited setting.

Other schemes

The middleware holds a requestsigning.Verifier, not a keyring, so a scheme this platform did not design — a proof in another header, in another format — is an implementation of that interface rather than a second copy of this middleware. Construct one and pass it here; the verifier locates its own proof on the request, so nothing in this package is specific to v1.

What this package does supply, whatever the scheme, is the bound: the body is read once and capped here, and the verifier is handed a request whose GetBody replays those capped bytes. A verifier cannot read past the cap, and cannot verify bytes the handler will not see.

What it does not do

It fails closed and cannot be configured otherwise. The rate limiter has a fail-open default because a limiter that cannot reach Redis is a fault in a guard rather than a verdict from it; there is no equivalent here. A signature that did not verify has not verified, and the only thing "letting it through anyway" would buy is an endpoint that is authenticated on paper.

It also says nothing about *who* signed. One keyring is one counterparty; a service with many needs a KeySource that resolves per caller, which is what requestsigning.KeySourceFunc is for.

The wire shape

Rejections render through routing.DefaultErrorBody: the platform APIError envelope with code E117, exactly what the Router produces for a handler that returned requestsigning.ErrInvalidSignature. A service that replaced that envelope passes its own encoder to WithErrorEncoder — the same one it gave the Router — so a 401 arrives in the shape its clients already parse.

Watching it

Three counters: requestsigning_http_verified, requestsigning_http_rejected, and requestsigning_http_errors. The third is the one to alert on. Rejections are a guard doing its job and rise whenever a counterparty misconfigures itself; errors mean this middleware could not reach a verdict at all — a key source it could not read, a body it could not buffer — and that is a fault in the service doing the verifying.

Index

Examples

Constants

View Source
const DefaultMaxBodySize int64 = 1 << 20

DefaultMaxBodySize bounds how much of an unverified request body the middleware will read.

One mebibyte, and the number matters: the body has to be buffered whole before a signature over it can be checked, which means an unauthenticated caller decides how much memory this guard allocates. A cap is the only thing standing between that and a request that costs the process more than the endpoint behind it would have.

Variables

View Source
var (
	// ErrNilVerifier indicates NewMiddleware was called without a verifier.
	ErrNilVerifier = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil request signature verifier")

	// ErrBodyTooLarge indicates a request body past the configured cap. It
	// wraps requestsigning.ErrInvalidSignature, and answers 401 rather than
	// 413, because what actually happened is that the request could not be
	// authenticated — and a distinct status here would tell an unauthenticated
	// caller exactly where the cap sits.
	ErrBodyTooLarge = platformerrors.Wrap(requestsigning.ErrInvalidSignature, "request body exceeds the signable limit")
)

Functions

func NewMiddleware

func NewMiddleware(verifier requestsigning.Verifier, opts ...Option) (routing.Middleware, error)

NewMiddleware builds middleware that verifies a request's signature before the handler runs and answers 401 when it does not check out.

It is the inbound half of what httpclient.WithRequestSigning does outbound, over the same requestsigning.Verifier — so a first-party caller and the service it calls can be configured from one scheme and one key source, and a third party's scheme plugs into the same seam.

Install it per route with routing.WithMiddleware, not globally with Router.Use. Verification requires the whole body in memory before the handler sees it, which is the right price on a callback endpoint and the wrong one on every upload route in the service. It also fails closed by construction: there is no configuration under which an unsigned request reaches the handler, because a guard that can be talked out of guarding is not one.

verifier, err := requestsigning.NewVerifier(keys)
if err != nil {
	return err
}

mw, err := requestsigninghttp.NewMiddleware(verifier,
	requestsigninghttp.WithMetricsProvider(pillars.Metrics))
if err != nil {
	return err
}

routing.Post(router, "/callbacks/payments", handler, routing.WithMiddleware(mw))

The body

The handler is handed the same bytes that were verified, replayed from memory. That is not a convenience: a handler that re-read the socket, or that decoded and re-encoded, would be acting on something other than what the signature covered, which is the exact bug this middleware exists to stop people writing by hand.

Example

The middleware is the inbound half of httpclient.WithRequestSigning: one verifier, one key source, and no handler that has to remember to check anything.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"

	"github.com/primandproper/primitives-go/v2/cryptography/requestsigning"
	requestsigninghttp "github.com/primandproper/primitives-go/v2/cryptography/requestsigning/http"
)

// The middleware is the inbound half of httpclient.WithRequestSigning: one
// verifier, one key source, and no handler that has to remember to check
// anything.
func main() {
	// In a real service this comes from secrets, via
	// requestsigning.NewSecretKeySource.
	keys := requestsigning.StaticKeyring(requestsigning.Keyring{Current: []byte("the shared key")})

	verifier, err := requestsigning.NewVerifier(keys)
	if err != nil {
		panic(err)
	}

	mw, err := requestsigninghttp.NewMiddleware(verifier)
	if err != nil {
		panic(err)
	}

	// The handler reads the verified bytes, not the socket. It has no signature
	// checking of its own, which is the point.
	handler := mw(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
		body, readErr := io.ReadAll(req.Body)
		if readErr != nil {
			res.WriteHeader(http.StatusBadRequest)

			return
		}

		fmt.Println("handled:", string(body))
		res.WriteHeader(http.StatusNoContent)
	}))

	server := httptest.NewServer(handler)
	defer server.Close()

	payload := `{"id":"order-7"}`

	signature, err := requestsigning.Sign(
		requestsigning.Keyring{Current: []byte("the shared key")},
		[]byte(payload), time.Now(),
	)
	if err != nil {
		panic(err)
	}

	fmt.Println("signed:  ", post(server.URL, payload, signature))
	fmt.Println("unsigned:", post(server.URL, payload, ""))

}

// post sends payload, with signature when there is one, and reports the status.
func post(url, payload, signature string) int {
	req, err := http.NewRequestWithContext(context.Background(),
		http.MethodPost, url, strings.NewReader(payload))
	if err != nil {
		panic(err)
	}

	if signature != "" {
		req.Header.Set(requestsigning.SignatureHeader, signature)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer func() { _ = res.Body.Close() }()

	return res.StatusCode
}
Output:
handled: {"id":"order-7"}
signed:   204
unsigned: 401

Types

type Option

type Option func(*config)

Option configures the middleware.

func WithErrorEncoder

func WithErrorEncoder(encoder routing.ErrorEncoder) Option

WithErrorEncoder renders the rejection the way the service renders every other error. Pass the same routing.ErrorEncoder the Router was built with: a service that replaced the platform envelope did so because its clients parse something else, and a 401 that arrives in a shape they cannot parse is a rejection they cannot act on.

Without it the platform APIError envelope is used, which is what the Router itself produces for a handler that returned requestsigning.ErrInvalidSignature. Both paths run through routing.DefaultErrorBody, so the two cannot drift.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger.

func WithMaxBodySize

func WithMaxBodySize(size int64) Option

WithMaxBodySize overrides DefaultMaxBodySize — how many bytes of an unverified body the middleware will buffer in order to check a signature over it. A request whose body exceeds it is rejected unverified.

Raise it for an endpoint that legitimately receives large signed payloads, and only that endpoint: install the middleware per route with routing.WithMiddleware rather than lifting the cap for the whole surface. A non-positive size leaves the default in place; there is no unlimited setting, because the cap is what makes the buffering safe.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider, enabling the middleware's verified, rejected, and error counters.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL