using Microsoft.Extensions.Options; using System.Security.Cryptography; using System.Text; namespace FrameworkSL.Middleware { /// /// Validates inbound webhook payloads on EIP channel endpoints before /// FastEndpoints deserializes the body. /// /// How it works: /// 1. Enables request body buffering (so the body can be read twice). /// 2. For paths under /EIPConversation/, reads the raw body and the /// channel-specific signature header. /// 3. Computes HMAC-SHA256 with the per-channel secret from appsettings. /// 4. Returns HTTP 401 if the signature is absent (when strict mode is on) /// or if the signature is present but invalid. /// 5. Rewinds the body so FastEndpoints can read it normally. /// /// Configuration (appsettings.json): /// "EIP": { /// "Enable": "Y", /// "WebhookStrictValidation": false, // false = warn-only; true = reject on missing signature /// "WebhookSecrets": { /// "WhatsApp": "your-meta-app-secret", // sha256 of body; X-Hub-Signature-256 header /// "Teams": "your-teams-hmac-key", // Authorization header (Bot Framework) /// "Telegram": "your-telegram-token" // X-Telegram-Bot-Api-Secret-Token header /// } /// } /// /// To enable: add app.UseMiddleware<EIPWebhookSignatureMiddleware>(); /// in Program.cs BEFORE app.UseFastEndpoints(). /// public class EIPWebhookSignatureMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IConfiguration _config; private static readonly HashSet _eipPaths = new(StringComparer.OrdinalIgnoreCase) { "/EIPConversation/RequestEIPConversation", "/EIPConversation/Receive" }; public EIPWebhookSignatureMiddleware( RequestDelegate next, ILogger logger, IConfiguration config) { _next = next; _logger = logger; _config = config; } public async Task InvokeAsync(HttpContext context) { var path = context.Request.Path.Value ?? string.Empty; // Only inspect EIP webhook endpoints if (!_eipPaths.Contains(path)) { await _next(context); return; } // Enable buffering so the body can be read here AND again by FastEndpoints context.Request.EnableBuffering(); try { var rawBody = await ReadBodyAsync(context.Request); var strictMode = _config.GetValue("EIP:WebhookStrictValidation"); var channelHeader = context.Request.Headers["X-EIP-Channel"].FirstOrDefault() ?? string.Empty; // Detect channel from header (WhatsApp sends X-Hub-Signature-256; // set X-EIP-Channel=WhatsApp in your Celitix/Meta webhook config) var valid = channelHeader.Equals("WhatsApp", StringComparison.OrdinalIgnoreCase) ? ValidateWhatsApp(context.Request, rawBody, strictMode) : ValidateGeneric(context.Request, rawBody, channelHeader, strictMode); if (!valid) { _logger.LogWarning( "EIP webhook signature validation FAILED | Path={Path} | Channel={Channel} | IP={IP}", path, channelHeader, context.Connection.RemoteIpAddress); context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync("Webhook signature invalid."); return; } } catch (Exception ex) { _logger.LogError(ex, "EIP webhook signature middleware error | Path={Path}", path); // Don't block on middleware error — let the endpoint handle the request } finally { // Rewind so FastEndpoints can read the body context.Request.Body.Seek(0, SeekOrigin.Begin); } await _next(context); } private bool ValidateWhatsApp(HttpRequest request, string rawBody, bool strictMode) { var secret = _config["EIP:WebhookSecrets:WhatsApp"]; var signatureHeader = request.Headers["X-Hub-Signature-256"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(signatureHeader)) { _logger.LogWarning("WhatsApp webhook missing X-Hub-Signature-256 header"); return !strictMode; // strict=false → warn-only; strict=true → reject } if (string.IsNullOrWhiteSpace(secret)) { _logger.LogWarning("EIP:WebhookSecrets:WhatsApp not configured — skipping signature check"); return true; } // Format: "sha256=" var expected = $"sha256={ComputeHmacSha256Hex(rawBody, secret)}"; return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signatureHeader)); } private bool ValidateGeneric(HttpRequest request, string rawBody, string channel, bool strictMode) { var secret = _config[$"EIP:WebhookSecrets:{channel}"]; if (string.IsNullOrWhiteSpace(secret)) return true; // no secret configured → skip // Generic: X-Webhook-Signature or X-Hub-Signature-256 var sigHeader = request.Headers["X-Hub-Signature-256"].FirstOrDefault() ?? request.Headers["X-Webhook-Signature"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(sigHeader)) return !strictMode; var expected = ComputeHmacSha256Hex(rawBody, secret); var provided = sigHeader.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase) ? sigHeader[7..] : sigHeader; return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(provided)); } private static async Task ReadBodyAsync(HttpRequest request) { request.Body.Seek(0, SeekOrigin.Begin); using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true); return await reader.ReadToEndAsync(); } private static string ComputeHmacSha256Hex(string body, string secret) { var key = Encoding.UTF8.GetBytes(secret); var data = Encoding.UTF8.GetBytes(body); var hash = HMACSHA256.HashData(key, data); return Convert.ToHexString(hash).ToLowerInvariant(); } } }