blob: 4513a27f2b9ba3e608df077ba74450e1544ef33c [file] [log] [blame]
// Utility methods for implementing authenticated webhooks.
//
// All requests must either be over a private channel (e.g. https) or must be
// idempotent and return no data. Requests sent via an open channel (e.g. http)
// could be resent by an attacker.
package webhook
import (
"bytes"
"context"
"crypto/sha512"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"go.skia.org/infra/go/metadata"
"go.skia.org/infra/go/secret"
"go.skia.org/infra/go/sklog"
skutil "go.skia.org/infra/go/util"
)
// Required header for requests to a webhook authenticated using AuthenticateRequest. The value must
// be set to the result of ComputeAuthHashBase64.
const REQUEST_AUTH_HASH_HEADER = "X-Webhook-Auth-Hash"
var requestSalt []byte = nil
// InitRequestSaltForTesting sets requestSalt to "notverysecret". Should be called once at startup
// when running in test mode.
//
// To test a webhook endpoint using curl, the following commands should work:
// $ DATA='my post request'
// $ AUTH="$(echo -n "${DATA}notverysecret" | sha512sum | xxd -r -p - | base64 -w 0)"
// $ curl -v -H "X-Webhook-Auth-Hash: $AUTH" -d "$DATA" http://localhost:8000/endpoint
func InitRequestSaltForTesting() {
requestSalt = []byte("notverysecret")
}
func setRequestSaltFromBase64(saltBase64 []byte) error {
enc := base64.StdEncoding
decodedLen := enc.DecodedLen(len(saltBase64))
salt := make([]byte, decodedLen)
n, err := enc.Decode(salt, saltBase64)
if err != nil {
return err
}
requestSalt = salt[:n]
return nil
}
// InitRequestSaltFromMetadata reads requestSalt from the specified project metadata
// and returns any error encountered. Should be called once at startup.
func InitRequestSaltFromMetadata(metadataKey string) error {
saltBase64, err := metadata.ProjectGet(metadataKey)
if err != nil {
return err
}
if err := setRequestSaltFromBase64([]byte(saltBase64)); err != nil {
return fmt.Errorf("Could not decode salt from %s: %s", metadataKey, err)
}
return nil
}
// MustInitRequestSaltFromMetadata reads requestSalt from the specified project
// metadata. Exits the program on error. Should be called once at startup.
func MustInitRequestSaltFromMetadata(metadataKey string) {
if err := InitRequestSaltFromMetadata(metadataKey); err != nil {
sklog.Fatal(err)
}
}
// InitRequestSaltFromFile reads requestSalt from the given file and returns any error encountered.
// Should be called once at startup.
func InitRequestSaltFromFile(filename string) error {
saltBase64Bytes, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("Could not read the webhook request salt file: %s", err)
}
if err = setRequestSaltFromBase64(saltBase64Bytes); err != nil {
return fmt.Errorf("Could not decode salt from %s: %s", filename, err)
}
return nil
}
// MustInitRequestSaltFromFile reads requestSalt from the given file. Exits the program on error.
// Should be called once at startup.
func MustInitRequestSaltFromFile(filename string) {
if err := InitRequestSaltFromFile(filename); err != nil {
sklog.Fatal(err)
}
}
// InitRequestSaltFromSecret reads requestSalt from the specified GCP secret
// and returns any error encountered. Should be called once at startup.
func InitRequestSaltFromSecret(project, secretName string) error {
ctx := context.Background()
secretClient, err := secret.NewClient(ctx)
if err != nil {
return err
}
saltBase64, err := secretClient.Get(ctx, project, secretName, secret.VersionLatest)
if err != nil {
return err
}
if err := setRequestSaltFromBase64([]byte(saltBase64)); err != nil {
return fmt.Errorf("Could not decode salt from %s: %s", secretName, err)
}
return nil
}
// MustInitRequestSaltFromSecret reads requestSalt from the specified GCP
// secret. Exits the program on error. Should be called once at startup.
func MustInitRequestSaltFromSecret(project, secret string) {
if err := InitRequestSaltFromSecret(project, secret); err != nil {
sklog.Fatal(err)
}
}
// Computes the value for REQUEST_AUTH_HASH_HEADER from the request body. Returns error if
// requestSalt has not been initialized. The result is the base64-encoded SHA-512 hash of the
// request body with requestSalt appended.
func ComputeAuthHashBase64(data []byte) (string, error) {
if len(requestSalt) == 0 {
return "", fmt.Errorf("requestSalt is uninitialized.")
}
data = append(data, requestSalt...)
hash := sha512.Sum512(data)
return base64.StdEncoding.EncodeToString(hash[:]), nil
}
// NewRequest is similar to http.NewRequest, but adds the REQUEST_AUTH_HASH_HEADER for
// authentication.
func NewRequest(method, urlStr string, body []byte) (*http.Request, error) {
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
if err != nil {
return nil, err
}
hash, err := ComputeAuthHashBase64(body)
if err != nil {
return nil, err
}
req.Header.Set(REQUEST_AUTH_HASH_HEADER, hash)
return req, nil
}
// Authenticates a webhook request.
// - If an error occurs reading r.Body, returns nil and the error.
// - If the request could not be authenticated as a webhook request, returns the contents of r.Body
// and an error.
// - Otherwise, returns the contents of r.Body and nil.
//
// In all cases, closes r.Body.
func AuthenticateRequest(r *http.Request) ([]byte, error) {
defer skutil.Close(r.Body)
data, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
headerHashBase64 := r.Header.Get(REQUEST_AUTH_HASH_HEADER)
if headerHashBase64 == "" {
return data, fmt.Errorf("No authentication header %s", REQUEST_AUTH_HASH_HEADER)
}
dataHashBase64, err := ComputeAuthHashBase64(data)
if err != nil {
return data, err
}
if headerHashBase64 == dataHashBase64 {
return data, nil
}
return data, fmt.Errorf("Authentication header %s: %s did not match.", REQUEST_AUTH_HASH_HEADER, headerHashBase64)
}