smallstep-certificates/authority/provisioner/webhook.go

240 lines
5.8 KiB
Go

package provisioner
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"text/template"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/internal/requestid"
"github.com/smallstep/certificates/templates"
"github.com/smallstep/certificates/webhook"
"go.step.sm/linkedca"
)
var ErrWebhookDenied = errors.New("webhook server did not allow request")
type WebhookSetter interface {
SetWebhook(string, any)
}
type WebhookController struct {
client *http.Client
webhooks []*Webhook
certType linkedca.Webhook_CertType
options []webhook.RequestBodyOption
TemplateData WebhookSetter
}
// Enrich fetches data from remote servers and adds returned data to the
// templateData
func (wc *WebhookController) Enrich(ctx context.Context, req *webhook.RequestBody) error {
if wc == nil {
return nil
}
// Apply extra options in the webhook controller
for _, fn := range wc.options {
if err := fn(req); err != nil {
return err
}
}
for _, wh := range wc.webhooks {
if wh.Kind != linkedca.Webhook_ENRICHING.String() {
continue
}
if !wc.isCertTypeOK(wh) {
continue
}
whCtx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel() //nolint:gocritic // every request canceled with its own timeout
resp, err := wh.DoWithContext(whCtx, wc.client, req, wc.TemplateData)
if err != nil {
return err
}
if !resp.Allow {
return ErrWebhookDenied
}
wc.TemplateData.SetWebhook(wh.Name, resp.Data)
}
return nil
}
// Authorize checks that all remote servers allow the request
func (wc *WebhookController) Authorize(ctx context.Context, req *webhook.RequestBody) error {
if wc == nil {
return nil
}
// Apply extra options in the webhook controller
for _, fn := range wc.options {
if err := fn(req); err != nil {
return err
}
}
for _, wh := range wc.webhooks {
if wh.Kind != linkedca.Webhook_AUTHORIZING.String() {
continue
}
if !wc.isCertTypeOK(wh) {
continue
}
whCtx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel() //nolint:gocritic // every request canceled with its own timeout
resp, err := wh.DoWithContext(whCtx, wc.client, req, wc.TemplateData)
if err != nil {
return err
}
if !resp.Allow {
return ErrWebhookDenied
}
}
return nil
}
func (wc *WebhookController) isCertTypeOK(wh *Webhook) bool {
if wc.certType == linkedca.Webhook_ALL {
return true
}
if wh.CertType == linkedca.Webhook_ALL.String() || wh.CertType == "" {
return true
}
return wc.certType.String() == wh.CertType
}
type Webhook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Kind string `json:"kind"`
DisableTLSClientAuth bool `json:"disableTLSClientAuth,omitempty"`
CertType string `json:"certType"`
Secret string `json:"-"`
BearerToken string `json:"-"`
BasicAuth struct {
Username string
Password string
} `json:"-"`
}
func (w *Webhook) DoWithContext(ctx context.Context, client *http.Client, reqBody *webhook.RequestBody, data any) (*webhook.ResponseBody, error) {
tmpl, err := template.New("url").Funcs(templates.StepFuncMap()).Parse(w.URL)
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
if err := tmpl.Execute(buf, data); err != nil {
return nil, err
}
url := buf.String()
/*
Sending the token to the webhook server is a security risk. A K8sSA
token can be reused multiple times. The webhook can misuse it to get
fake certificates. A webhook can misuse any other token to get its own
certificate before responding.
switch tmpl := data.(type) {
case x509util.TemplateData:
reqBody.Token = tmpl[x509util.TokenKey]
case sshutil.TemplateData:
reqBody.Token = tmpl[sshutil.TokenKey]
}
*/
reqBody.Timestamp = time.Now()
reqBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
retries := 1
retry:
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(reqBytes))
if err != nil {
return nil, err
}
if requestID, ok := requestid.FromContext(ctx); ok {
req.Header.Set("X-Request-Id", requestID)
}
secret, err := base64.StdEncoding.DecodeString(w.Secret)
if err != nil {
return nil, err
}
h := hmac.New(sha256.New, secret)
h.Write(reqBytes)
sig := h.Sum(nil)
req.Header.Set("X-Smallstep-Signature", hex.EncodeToString(sig))
req.Header.Set("X-Smallstep-Webhook-ID", w.ID)
if w.BearerToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", w.BearerToken))
} else if w.BasicAuth.Username != "" || w.BasicAuth.Password != "" {
req.SetBasicAuth(w.BasicAuth.Username, w.BasicAuth.Password)
}
if w.DisableTLSClientAuth {
transport, ok := client.Transport.(*http.Transport)
if !ok {
return nil, errors.New("client transport is not a *http.Transport")
}
transport = transport.Clone()
tlsConfig := transport.TLSClientConfig.Clone()
tlsConfig.GetClientCertificate = nil
tlsConfig.Certificates = nil
transport.TLSClientConfig = tlsConfig
client = &http.Client{
Transport: transport,
}
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, err
} else if retries > 0 {
retries--
time.Sleep(time.Second)
goto retry
}
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Printf("Failed to close body of response from %s", w.URL)
}
}()
if resp.StatusCode >= 500 && retries > 0 {
retries--
time.Sleep(time.Second)
goto retry
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("Webhook server responded with %d", resp.StatusCode)
}
respBody := &webhook.ResponseBody{}
if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil {
return nil, err
}
return respBody, nil
}