Files
phishingclub/backend/service/webhook.go
Ronni Skansing 8bf457c592 Added webhook data level and events filtering
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-12-16 22:15:57 +01:00

419 lines
11 KiB
Go

package service
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
)
type Webhook struct {
Common
CampaignRepository *repository.Campaign
WebhookRepository *repository.Webhook
}
func (w *Webhook) Create(
ctx context.Context,
session *model.Session,
webhook *model.Webhook,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Webhook.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
w.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return nil, errors.New("unauthorized")
}
// validate data
if err := webhook.Validate(); err != nil {
return nil, errs.Wrap(err)
}
// check uniqueness
var companyID *uuid.UUID
if cid, err := webhook.CompanyID.Get(); err == nil {
companyID = &cid
}
name := webhook.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
w.WebhookRepository.DB,
"webhooks",
name.String(),
companyID,
nil,
)
if err != nil {
w.Logger.Errorw("failed to check webhook uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
w.Logger.Debugw("webhook name is already taken", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// insert
id, err := w.WebhookRepository.Insert(ctx, webhook)
if err != nil {
w.Logger.Errorw("failed to insert webhook", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
w.AuditLogAuthorized(ae)
return id, nil
}
// GetAll gets all webhooks
func (w *Webhook) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.WebhookOption,
) (*model.Result[model.Webhook], error) {
result := model.NewEmptyResult[model.Webhook]()
ae := NewAuditEvent("Webhook.GetAll", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
w.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get
result, err = w.WebhookRepository.GetAll(ctx, companyID, options)
if err != nil {
w.Logger.Errorw("failed to get webhooks", "error", err)
return result, errs.Wrap(err)
}
w.AuditLogAuthorized(ae)
return result, nil
}
// GetByID gets a webhook by id
func (w *Webhook) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) (*model.Webhook, error) {
ae := NewAuditEvent("Webhook.GetByID", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
w.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
out, err := w.WebhookRepository.GetByID(ctx, id)
if err != nil {
w.Logger.Errorw("failed to get webhook", "error", err)
return out, errs.Wrap(err)
}
// no audit on read
return out, nil
}
// GetByCompanyID gets a webhooks by compnay id
func (w *Webhook) GetByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) ([]*model.Webhook, error) {
ae := NewAuditEvent("Webhook.GetByCompanyID", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
w.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
models, err := w.WebhookRepository.GetAllByCompanyID(ctx, companyID, &repository.WebhookOption{})
if err != nil {
w.Logger.Errorw("failed to get webhooks", "error", err)
return models, errs.Wrap(err)
}
// no audit on read
return models, nil
}
// Update updates a webhook
func (w *Webhook) Update(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
webhook *model.Webhook,
) error {
ae := NewAuditEvent("Webhook.Update", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
w.LogAuthError(err)
return err
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return errors.New("unauthorized")
}
// get current
current, err := w.WebhookRepository.GetByID(ctx, id)
if err != nil {
w.Logger.Errorw("failed to get webhook", "error", err)
return err
}
// update values
if v, err := webhook.Name.Get(); err == nil {
// check uniqueness
var companyID *uuid.UUID
if cid, err := webhook.CompanyID.Get(); err == nil {
companyID = &cid
}
isOK, err := repository.CheckNameIsUnique(
ctx,
w.WebhookRepository.DB,
"webhooks",
v.String(),
companyID,
id,
)
if err != nil {
w.Logger.Errorw("failed to check webhook uniqueness", "error", err)
return err
}
if !isOK {
w.Logger.Debugw("webhook name is already taken", "name", v.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
current.Name.Set(v)
}
if v, err := webhook.URL.Get(); err == nil {
current.URL.Set(v)
}
if v, err := webhook.Secret.Get(); err == nil {
current.Secret.Set(v)
}
// update
err = w.WebhookRepository.UpdateByID(ctx, id, webhook)
if err != nil {
w.Logger.Errorw("failed to update webhook", "error", err)
return err
}
w.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a webhook
func (w *Webhook) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Webhook.DeleteByID", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
w.LogAuthError(err)
return err
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return errors.New("unauthorized")
}
// get campaigns afffected so we can remove webhoook from them
affectedCampaigns, err := w.CampaignRepository.GetByWebhookID(
ctx,
id,
)
if err != nil {
w.Logger.Errorw("failed to get campaigns afffected by removing webhhook", "error", err)
return err
}
cids := []*uuid.UUID{}
for _, campaign := range affectedCampaigns {
cid := campaign.ID.MustGet()
cids = append(cids, &cid)
}
err = w.CampaignRepository.RemoveWebhookByCampaignIDs(
ctx,
cids,
)
if err != nil {
w.Logger.Errorw("failed to remove web hook from campaigns", "error", err)
return err
}
// delete
err = w.WebhookRepository.DeleteByID(ctx, id)
if err != nil {
w.Logger.Errorw("failed to delete webhook", "error", err)
return err
}
w.AuditLogAuthorized(ae)
return nil
}
// SendTest sends a test webhook
func (w *Webhook) SendTest(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) (map[string]interface{}, error) {
ae := NewAuditEvent("Webhook.SendTest", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
w.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
w.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
w.Logger.Debugw("sending test webhook", "error", id)
// send
webhook, err := w.WebhookRepository.GetByID(ctx, id)
if err != nil {
w.Logger.Errorw("failed to get webhook", "error", err)
return nil, errs.Wrap(err)
}
now := time.Now()
request := WebhookRequest{
Time: &now,
CampaignName: "Test Campaign",
Email: "test@webhook.test",
Event: "test",
}
data, err := w.Send(ctx, webhook, &request)
if err != nil {
w.Logger.Errorw("failed to send webhook", "error", err)
return nil, errs.Wrap(err)
}
w.AuditLogAuthorized(ae)
return data, nil
}
// Send sends a webhook request
func (w *Webhook) Send(
ctx context.Context,
webhook *model.Webhook,
request *WebhookRequest,
) (map[string]interface{}, error) {
reqCtx, reqCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer func() {
reqCancel()
}()
requestJSON, err := json.Marshal(request)
if err != nil {
return nil, errs.Wrap(err)
}
requestJSONBuffer := bytes.NewBuffer(requestJSON)
url := webhook.URL.MustGet()
req, err := http.NewRequestWithContext(reqCtx, "POST", url.String(), requestJSONBuffer)
if err != nil {
return nil, errs.Wrap(err)
}
req.Header.Set("Content-Type", "application/json")
// hmac sign the request if secret is set
var signature = "UNSIGNED"
if secret, err := webhook.Secret.Get(); err == nil {
hasher := hmac.New(sha256.New, []byte(secret.String()))
_, err := hasher.Write(requestJSON)
if err != nil {
return nil, errs.Wrap(err)
}
signature = hex.EncodeToString(hasher.Sum(nil))
}
req.Header.Set("X-SIGNATURE", signature)
req.Header.Add("User-Agent", "Go-http-client")
response, err := http.DefaultClient.Do(req)
if err != nil {
return nil, errs.Wrap(err)
}
data := map[string]interface{}{
"code": response.StatusCode,
"status": response.Status,
}
// parse respone body
body, err := io.ReadAll(response.Body)
if err != nil {
w.Logger.Errorw("failed to read response body", "error", err)
return nil, errs.Wrap(err)
}
defer response.Body.Close()
data["body"] = string(body)
return data, nil
}
// WebhookRequest represents the payload sent to webhook endpoints.
// webhooks are sent based on the campaign's webhookEvents setting (stored as bitwise int):
// - 0: all events trigger webhooks (default, backward compatible)
// - non-zero: only events with their bit set trigger webhooks
//
// webhook events (10 total - events that call HandleWebhook):
// from campaign.go (4 events):
// - campaign_closed: when a campaign finishes
// - campaign_recipient_message_sent: when an email is successfully sent
// - campaign_recipient_message_failed: when an email fails to send
// - campaign_recipient_message_read: when tracking pixel is loaded
// from proxy.go (6 events):
// - campaign_recipient_submitted_data: when user submits data on phishing page
// - campaign_recipient_evasion_page_visited: when evasion page is visited
// - campaign_recipient_before_page_visited: when before page is visited
// - campaign_recipient_page_visited: when landing page is visited
// - campaign_recipient_after_page_visited: when after page is visited
// - campaign_recipient_deny_page_visited: when deny page is visited
//
// the fields included depend on the campaign's webhookIncludeData setting:
// - "none": only Time and Event are sent (maximum privacy)
// - "basic": Time, Event, and CampaignName are sent (no PII)
// - "full": all fields including Email and Data are sent (complete information)
type WebhookRequest struct {
Time *time.Time `json:"time"`
CampaignName string `json:"campaignName,omitempty"`
Email string `json:"email,omitempty"`
Event string `json:"event"`
Data map[string]interface{} `json:"data,omitempty"`
}