mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2025-12-20 00:00:13 -06:00
Fix slack challenge + interactive webhook (#2612)
* Fix slack challage * ensure we continue if its not a challange * fix * update doc string * PR feedback + lint * more debug logs * more logging * more logging * clean * revert challange stuff + update error message * Update log + error message * More warn + unsanitized returns
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"hash"
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,6 +30,8 @@ func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1Web
|
|||||||
tenantId := request.Tenant.String()
|
tenantId := request.Tenant.String()
|
||||||
webhookName := request.V1Webhook
|
webhookName := request.V1Webhook
|
||||||
|
|
||||||
|
w.config.Logger.Debug().Str("webhook", webhookName).Str("tenant", tenantId).Str("method", ctx.Request().Method).Str("content_type", ctx.Request().Header.Get("Content-Type")).Msg("received webhook request")
|
||||||
|
|
||||||
tenant, err := w.config.APIRepository.Tenant().GetTenantByID(ctx.Request().Context(), tenantId)
|
tenant, err := w.config.APIRepository.Tenant().GetTenantByID(ctx.Request().Context(), tenantId)
|
||||||
|
|
||||||
if err != nil || tenant == nil {
|
if err != nil || tenant == nil {
|
||||||
@@ -99,18 +102,98 @@ func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1Web
|
|||||||
payloadMap := make(map[string]interface{})
|
payloadMap := make(map[string]interface{})
|
||||||
|
|
||||||
if rawBody != nil {
|
if rawBody != nil {
|
||||||
err := json.Unmarshal(rawBody, &payloadMap)
|
contentType := ctx.Request().Header.Get("Content-Type")
|
||||||
|
|
||||||
|
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||||
|
formData, err := url.ParseQuery(string(rawBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errorMsg := "Failed to parse form data"
|
||||||
|
w.config.Logger.Info().Err(err).Str("webhook", webhookName).Str("tenant", tenantId).Msg(errorMsg)
|
||||||
return gen.V1WebhookReceive400JSONResponse{
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
Errors: []gen.APIError{
|
Errors: []gen.APIError{
|
||||||
{
|
{
|
||||||
Description: fmt.Sprintf("failed to unmarshal request body: %v", err),
|
Description: errorMsg,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Slack interactive payloads use a 'payload' parameter containing JSON
|
||||||
|
* See: https://docs.slack.dev/interactivity/handling-user-interaction/#payloads
|
||||||
|
* For GENERIC webhooks, we convert all form fields directly to the payload map
|
||||||
|
*/
|
||||||
|
if webhook.SourceName == sqlcv1.V1IncomingWebhookSourceNameSLACK {
|
||||||
|
payloadValue := formData.Get("payload")
|
||||||
|
if payloadValue == "" {
|
||||||
|
errorMsg := "missing payload parameter in form-encoded request"
|
||||||
|
w.config.Logger.Info().Str("webhook", webhookName).Str("tenant", tenantId).Str("form_keys", fmt.Sprintf("%v", func() []string {
|
||||||
|
keys := make([]string, 0, len(formData))
|
||||||
|
for k := range formData {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}())).Msg(errorMsg)
|
||||||
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
|
Errors: []gen.APIError{
|
||||||
|
{
|
||||||
|
Description: errorMsg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
/* url.ParseQuery automatically URL-decodes the payload parameter value */
|
||||||
|
err := json.Unmarshal([]byte(payloadValue), &payloadMap)
|
||||||
|
if err != nil {
|
||||||
|
payloadPreview := payloadValue
|
||||||
|
if len(payloadPreview) > 200 {
|
||||||
|
payloadPreview = payloadPreview[:200] + "..."
|
||||||
|
}
|
||||||
|
errorMsg := "Failed to unmarshal payload parameter as JSON"
|
||||||
|
w.config.Logger.Info().Err(err).Str("webhook", webhookName).Str("tenant", tenantId).Int("payload_length", len(payloadValue)).Str("payload_preview", payloadPreview).Msg(errorMsg)
|
||||||
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
|
Errors: []gen.APIError{
|
||||||
|
{
|
||||||
|
Description: errorMsg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
} else if webhook.SourceName == sqlcv1.V1IncomingWebhookSourceNameGENERIC {
|
||||||
|
/* For GENERIC webhooks, convert all form fields to the payload map */
|
||||||
|
for key, values := range formData {
|
||||||
|
if len(values) > 0 {
|
||||||
|
payloadMap[key] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* For other webhook sources, form-encoded data is unexpected - return error */
|
||||||
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
|
Errors: []gen.APIError{
|
||||||
|
{
|
||||||
|
Description: fmt.Sprintf("form-encoded requests are not supported for webhook source: %s", webhook.SourceName),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := json.Unmarshal(rawBody, &payloadMap)
|
||||||
|
if err != nil {
|
||||||
|
bodyPreview := string(rawBody)
|
||||||
|
if len(bodyPreview) > 200 {
|
||||||
|
bodyPreview = bodyPreview[:200] + "..."
|
||||||
|
}
|
||||||
|
errorMsg := "Failed to unmarshal request body as JSON"
|
||||||
|
w.config.Logger.Info().Err(err).Str("webhook", webhookName).Str("tenant", tenantId).Str("content_type", contentType).Int("body_length", len(rawBody)).Str("body_preview", bodyPreview).Msg(errorMsg)
|
||||||
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
|
Errors: []gen.APIError{
|
||||||
|
{
|
||||||
|
Description: "failed to unmarshal request body",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This could cause unexpected behavior if the payload contains a key named "tenant" or "v1-webhook"
|
// This could cause unexpected behavior if the payload contains a key named "tenant" or "v1-webhook"
|
||||||
delete(payloadMap, "tenant")
|
delete(payloadMap, "tenant")
|
||||||
delete(payloadMap, "v1-webhook")
|
delete(payloadMap, "v1-webhook")
|
||||||
@@ -131,10 +214,17 @@ func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1Web
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if eventKey == "" {
|
var errorMsg string
|
||||||
err = fmt.Errorf("event key evaluted to an empty string")
|
if strings.Contains(err.Error(), "did not evaluate to a string") {
|
||||||
|
errorMsg = "Event key expression must evaluate to a string"
|
||||||
|
} else if eventKey == "" {
|
||||||
|
errorMsg = "Event key evaluated to an empty string"
|
||||||
|
} else {
|
||||||
|
errorMsg = "Failed to evaluate event key expression"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.config.Logger.Warn().Err(err).Str("webhook", webhookName).Str("tenant", tenantId).Str("event_key_expression", webhook.EventKeyExpression).Msg(errorMsg)
|
||||||
|
|
||||||
ingestionErr := w.config.Ingestor.IngestCELEvaluationFailure(
|
ingestionErr := w.config.Ingestor.IngestCELEvaluationFailure(
|
||||||
ctx.Request().Context(),
|
ctx.Request().Context(),
|
||||||
tenant.ID.String(),
|
tenant.ID.String(),
|
||||||
@@ -149,7 +239,7 @@ func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1Web
|
|||||||
return gen.V1WebhookReceive400JSONResponse{
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
Errors: []gen.APIError{
|
Errors: []gen.APIError{
|
||||||
{
|
{
|
||||||
Description: fmt.Sprintf("failed to evaluate event key expression: %v", err),
|
Description: errorMsg,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -160,7 +250,7 @@ func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1Web
|
|||||||
return gen.V1WebhookReceive400JSONResponse{
|
return gen.V1WebhookReceive400JSONResponse{
|
||||||
Errors: []gen.APIError{
|
Errors: []gen.APIError{
|
||||||
{
|
{
|
||||||
Description: fmt.Sprintf("failed to marshal request body: %v", err),
|
Description: "Failed to marshal request body",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -261,11 +351,15 @@ type IsChallenge bool
|
|||||||
func (w *V1WebhooksService) performChallenge(webhookPayload []byte, webhook sqlcv1.V1IncomingWebhook, request http.Request) (IsChallenge, map[string]interface{}, error) {
|
func (w *V1WebhooksService) performChallenge(webhookPayload []byte, webhook sqlcv1.V1IncomingWebhook, request http.Request) (IsChallenge, map[string]interface{}, error) {
|
||||||
switch webhook.SourceName {
|
switch webhook.SourceName {
|
||||||
case sqlcv1.V1IncomingWebhookSourceNameSLACK:
|
case sqlcv1.V1IncomingWebhookSourceNameSLACK:
|
||||||
|
/* Slack Events API URL verification challenges come as application/json with direct JSON payload
|
||||||
|
* Interactive components are form-encoded but are NOT challenges - they're regular events
|
||||||
|
* See: https://docs.slack.dev/apis/events-api/using-http-request-urls/#challenge
|
||||||
|
*/
|
||||||
payload := make(map[string]interface{})
|
payload := make(map[string]interface{})
|
||||||
err := json.Unmarshal(webhookPayload, &payload)
|
err := json.Unmarshal(webhookPayload, &payload)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, fmt.Errorf("failed to parse form data: %s", err)
|
/* If we can't parse JSON, it's likely not a challenge - let normal processing handle it */
|
||||||
|
return false, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if challenge, ok := payload["challenge"].(string); ok && challenge != "" {
|
if challenge, ok := payload["challenge"].(string); ok && challenge != "" {
|
||||||
@@ -300,7 +394,7 @@ func (w *V1WebhooksService) validateWebhook(webhookPayload []byte, webhook sqlcv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, &ValidationError{
|
return false, &ValidationError{
|
||||||
Code: Http403,
|
Code: Http403,
|
||||||
ErrorText: fmt.Sprintf("invalid timestamp in header: %s", err),
|
ErrorText: "Invalid timestamp in header",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +496,7 @@ func (w *V1WebhooksService) validateWebhook(webhookPayload []byte, webhook sqlcv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, &ValidationError{
|
return false, &ValidationError{
|
||||||
Code: Http400,
|
Code: Http400,
|
||||||
ErrorText: fmt.Sprintf("invalid timestamp in signature header: %s", err),
|
ErrorText: "Invalid timestamp in signature header",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user