github.com/stripe/stripe-go/v76@v76.25.0/webhook/client.go (about)

     1  package webhook
     2  
     3  import (
     4  	"crypto/hmac"
     5  	"crypto/sha256"
     6  	"encoding/hex"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/stripe/stripe-go/v76"
    15  )
    16  
    17  //
    18  // Public constants
    19  //
    20  
    21  const (
    22  	// DefaultTolerance indicates that signatures older than this will be rejected by ConstructEvent.
    23  	DefaultTolerance time.Duration = 300 * time.Second
    24  	// signingVersion represents the version of the signature we currently use.
    25  	signingVersion string = "v1"
    26  )
    27  
    28  //
    29  // Public variables
    30  //
    31  
    32  // This block represents the list of errors that could be raised when using the webhook package.
    33  var (
    34  	ErrInvalidHeader    = errors.New("webhook has invalid Stripe-Signature header")
    35  	ErrNoValidSignature = errors.New("webhook had no valid signature")
    36  	ErrNotSigned        = errors.New("webhook has no Stripe-Signature header")
    37  	ErrTooOld           = errors.New("timestamp wasn't within tolerance")
    38  )
    39  
    40  //
    41  // Public functions
    42  //
    43  
    44  // ComputeSignature computes a webhook signature using Stripe's v1 signing
    45  // method.
    46  //
    47  // See https://stripe.com/docs/webhooks#signatures for more information.
    48  func ComputeSignature(t time.Time, payload []byte, secret string) []byte {
    49  	mac := hmac.New(sha256.New, []byte(secret))
    50  	mac.Write([]byte(fmt.Sprintf("%d", t.Unix())))
    51  	mac.Write([]byte("."))
    52  	mac.Write(payload)
    53  	return mac.Sum(nil)
    54  }
    55  
    56  // ConstructEvent initializes an Event object from a JSON webhook payload, validating
    57  // the Stripe-Signature header using the specified signing secret. Returns an error
    58  // if the body or Stripe-Signature header provided are unreadable, if the
    59  // signature doesn't match, or if the timestamp for the signature is older than
    60  // DefaultTolerance.
    61  //
    62  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
    63  // your signing secret from the Stripe dashboard:
    64  // https://dashboard.stripe.com/webhooks
    65  //
    66  // This will return an error if the event API version does not match the
    67  // stripe.APIVersion constant.
    68  func ConstructEvent(payload []byte, header string, secret string) (stripe.Event, error) {
    69  	return ConstructEventWithTolerance(payload, header, secret, DefaultTolerance)
    70  }
    71  
    72  // ConstructEventIgnoringTolerance initializes an Event object from a JSON webhook
    73  // payload, validating the Stripe-Signature header using the specified signing secret.
    74  // Returns an error if the body or Stripe-Signature header provided are unreadable or
    75  // if the signature doesn't match. Does not check the signature's timestamp.
    76  //
    77  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
    78  // your signing secret from the Stripe dashboard:
    79  // https://dashboard.stripe.com/webhooks
    80  //
    81  // This will return an error if the event API version does not match the
    82  // stripe.APIVersion constant.
    83  func ConstructEventIgnoringTolerance(payload []byte, header string, secret string) (stripe.Event, error) {
    84  	return constructEvent(payload, header, secret, ConstructEventOptions{IgnoreTolerance: true})
    85  }
    86  
    87  // ConstructEventWithTolerance initializes an Event object from a JSON webhook payload,
    88  // validating the signature in the Stripe-Signature header using the specified signing
    89  // secret and tolerance window. Returns an error if the body or Stripe-Signature header
    90  // provided are unreadable, if the signature doesn't match, or if the timestamp
    91  // for the signature is older than the specified tolerance.
    92  //
    93  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
    94  // your signing secret from the Stripe dashboard:
    95  // https://dashboard.stripe.com/webhooks
    96  //
    97  // This will return an error if the event API version does not match the
    98  // stripe.APIVersion constant.
    99  func ConstructEventWithTolerance(payload []byte, header string, secret string, tolerance time.Duration) (stripe.Event, error) {
   100  	return constructEvent(payload, header, secret, ConstructEventOptions{Tolerance: tolerance})
   101  }
   102  
   103  // ConstructEventWithOptions initializes an Event object from a JSON webhook payload,
   104  // validating the signature in the Stripe-Signature header using the specified signing
   105  // secret and tolerance window provided by the options, if applicable.
   106  //
   107  // See `ConstructEventOptions` for more details on each of the options.
   108  //
   109  // Returns an error if the signature doesn't match, or:
   110  //   - if `IgnoreTolerance` is false and the timestamp embedded in the event
   111  //     header is not within the tolerance window (similar to `ConstructEventWithTolerance`)
   112  //   - if `IgnoreAPIVersionMismatch` is false and the webhook event API version
   113  //     does not match the API version of the stripe-go library, as defined in
   114  //     `stripe.APIVersion`.
   115  //
   116  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
   117  // your signing secret from the Stripe dashboard:
   118  // https://dashboard.stripe.com/webhooks
   119  func ConstructEventWithOptions(payload []byte, header string, secret string, options ConstructEventOptions) (stripe.Event, error) {
   120  	return constructEvent(payload, header, secret, options)
   121  }
   122  
   123  // ValidatePayload validates the payload against the Stripe-Signature header
   124  // using the specified signing secret. Returns an error if the body or
   125  // Stripe-Signature header provided are unreadable, if the signature doesn't
   126  // match, or if the timestamp for the signature is older than DefaultTolerance.
   127  //
   128  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
   129  // your signing secret from the Stripe dashboard:
   130  // https://dashboard.stripe.com/webhooks
   131  func ValidatePayload(payload []byte, header string, secret string) error {
   132  	return ValidatePayloadWithTolerance(payload, header, secret, DefaultTolerance)
   133  }
   134  
   135  // ValidatePayloadIgnoringTolerance validates the payload against the Stripe-Signature header
   136  // using the specified signing secret. Returns an error if the body or
   137  // Stripe-Signature header provided are unreadable or if the signature doesn't match.
   138  // Does not check the signature's timestamp.
   139  //
   140  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
   141  // your signing secret from the Stripe dashboard:
   142  // https://dashboard.stripe.com/webhooks
   143  func ValidatePayloadIgnoringTolerance(payload []byte, header string, secret string) error {
   144  	return validatePayload(payload, header, secret, 0*time.Second, false)
   145  }
   146  
   147  // ValidatePayloadWithTolerance validates the payload against the Stripe-Signature header
   148  // using the specified signing secret and tolerance window. Returns an error if the body
   149  // or Stripe-Signature header provided are unreadable, if the signature doesn't match, or
   150  // if the timestamp for the signature is older than the specified tolerance.
   151  //
   152  // NOTE: Stripe will only send Webhook signing headers after you have retrieved
   153  // your signing secret from the Stripe dashboard:
   154  // https://dashboard.stripe.com/webhooks
   155  func ValidatePayloadWithTolerance(payload []byte, header string, secret string, tolerance time.Duration) error {
   156  	return validatePayload(payload, header, secret, tolerance, true)
   157  }
   158  
   159  type ConstructEventOptions struct {
   160  	// Validates event timestamps using a custom Tolerance window. If this is
   161  	// not set and `IgnoreTolerance` is false, will default to
   162  	// `DefaultTolerance`.
   163  	Tolerance time.Duration
   164  
   165  	// If set to true, will ignore the `tolerance` option entirely and will not
   166  	// check the event signature's timestamp. Defaults to false. When false,
   167  	// constructing an event will fail with an error if the timestamp is not
   168  	// within the `Tolerance` window.
   169  	IgnoreTolerance bool
   170  
   171  	// If set to true, will ignore validating whether an event's API version
   172  	// matches the stripe-go API version. Defaults to false, returning an error
   173  	// when there is a mismatch.
   174  	IgnoreAPIVersionMismatch bool
   175  }
   176  
   177  //
   178  // Private types
   179  //
   180  
   181  type signedHeader struct {
   182  	timestamp  time.Time
   183  	signatures [][]byte
   184  }
   185  
   186  //
   187  // Private functions
   188  //
   189  
   190  func constructEvent(payload []byte, sigHeader string, secret string, options ConstructEventOptions) (stripe.Event, error) {
   191  	e := stripe.Event{}
   192  
   193  	tolerance := options.Tolerance
   194  	if options.Tolerance == 0 && !options.IgnoreTolerance {
   195  		tolerance = DefaultTolerance
   196  	}
   197  
   198  	if err := validatePayload(payload, sigHeader, secret, tolerance, !options.IgnoreTolerance); err != nil {
   199  		return e, err
   200  	}
   201  
   202  	if err := json.Unmarshal(payload, &e); err != nil {
   203  		return e, fmt.Errorf("Failed to parse webhook body json: %s", err.Error())
   204  	}
   205  
   206  	if !options.IgnoreAPIVersionMismatch && e.APIVersion != stripe.APIVersion {
   207  		return e, fmt.Errorf("Received event with API version %s, but stripe-go %s expects API version %s. We recommend that you create a WebhookEndpoint with this API version. Otherwise, you can disable this error by using `ConstructEventWithOptions(..., ConstructEventOptions{..., ignoreAPIVersionMismatch: true})`  but be wary that objects may be incorrectly deserialized.", e.APIVersion, stripe.ClientVersion, stripe.APIVersion)
   208  	}
   209  
   210  	return e, nil
   211  
   212  }
   213  
   214  func parseSignatureHeader(header string) (*signedHeader, error) {
   215  	sh := &signedHeader{}
   216  
   217  	if header == "" {
   218  		return sh, ErrNotSigned
   219  	}
   220  
   221  	// Signed header looks like "t=1495999758,v1=ABC,v1=DEF,v0=GHI"
   222  	pairs := strings.Split(header, ",")
   223  	for _, pair := range pairs {
   224  		parts := strings.Split(pair, "=")
   225  		if len(parts) != 2 {
   226  			return sh, ErrInvalidHeader
   227  		}
   228  
   229  		switch parts[0] {
   230  		case "t":
   231  			timestamp, err := strconv.ParseInt(parts[1], 10, 64)
   232  			if err != nil {
   233  				return sh, ErrInvalidHeader
   234  			}
   235  			sh.timestamp = time.Unix(timestamp, 0)
   236  
   237  		case signingVersion:
   238  			sig, err := hex.DecodeString(parts[1])
   239  			if err != nil {
   240  				continue // Ignore invalid signatures
   241  			}
   242  
   243  			sh.signatures = append(sh.signatures, sig)
   244  
   245  		default:
   246  			continue // Ignore unknown parts of the header
   247  		}
   248  	}
   249  
   250  	if len(sh.signatures) == 0 {
   251  		return sh, ErrNoValidSignature
   252  	}
   253  
   254  	return sh, nil
   255  }
   256  
   257  func validatePayload(payload []byte, sigHeader string, secret string, tolerance time.Duration, enforceTolerance bool) error {
   258  
   259  	header, err := parseSignatureHeader(sigHeader)
   260  	if err != nil {
   261  		return err
   262  	}
   263  
   264  	expectedSignature := ComputeSignature(header.timestamp, payload, secret)
   265  	expiredTimestamp := time.Since(header.timestamp) > tolerance
   266  	if enforceTolerance && expiredTimestamp {
   267  		return ErrTooOld
   268  	}
   269  
   270  	// Check all given v1 signatures, multiple signatures will be sent temporarily in the case of a rolled signature secret
   271  	for _, sig := range header.signatures {
   272  		if hmac.Equal(expectedSignature, sig) {
   273  			return nil
   274  		}
   275  	}
   276  
   277  	return ErrNoValidSignature
   278  }
   279  
   280  // For mocking webhook events
   281  type UnsignedPayload struct {
   282  	Payload   []byte
   283  	Secret    string
   284  	Timestamp time.Time
   285  	Scheme    string
   286  }
   287  
   288  type SignedPayload struct {
   289  	UnsignedPayload
   290  
   291  	Signature []byte
   292  	Header    string
   293  }
   294  
   295  func GenerateTestSignedPayload(options *UnsignedPayload) *SignedPayload {
   296  	signedPayload := &SignedPayload{UnsignedPayload: *options}
   297  
   298  	if signedPayload.Timestamp == (time.Time{}) {
   299  		signedPayload.Timestamp = time.Now()
   300  	}
   301  
   302  	if signedPayload.Scheme == "" {
   303  		signedPayload.Scheme = "v1"
   304  	}
   305  
   306  	signedPayload.Signature = ComputeSignature(signedPayload.Timestamp, signedPayload.Payload, signedPayload.Secret)
   307  	signedPayload.Header = generateHeader(*signedPayload)
   308  
   309  	return signedPayload
   310  }
   311  
   312  func generateHeader(p SignedPayload) string {
   313  	return fmt.Sprintf("t=%d,%s=%s", p.Timestamp.Unix(), p.Scheme, hex.EncodeToString(p.Signature))
   314  }