From 8b3f9284df033644fb4084e58de67d17a4be964e Mon Sep 17 00:00:00 2001 From: warrenchen Date: Thu, 26 Feb 2026 17:47:29 +0900 Subject: [PATCH] feat: Enhance SES event processing with signature validation and configuration options --- .env.example | 3 + docs/INSTALL.md | 5 + docs/OPENAPI.md | 4 +- .../Services/SesEventProcessingService.cs | 196 +++++++++++++++++- 4 files changed, 197 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index e11828e..7f0209b 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ Webhook__Secrets__member_center=change_me_webhook_secret Webhook__TimestampSkewSeconds=300 Webhook__AllowNullTenantClient=false Ses__SkipSignatureValidation=true +Ses__AllowedTopicArns= +Ses__AllowedCertHosts= +Ses__SignatureMaxSkewSeconds=300 Bounce__SoftBounceThreshold=5 MemberCenter__BaseUrl= MemberCenter__DisableSubscriptionPath=/subscriptions/disable diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 72975c0..12ac2b1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -65,6 +65,11 @@ - `DevSender__PollIntervalSeconds`:輪詢間隔秒數(預設 5) - `ESP__Provider=ses` 時,即使 `DevSender__Enabled=false`,背景 sender 仍會啟動並改用 SES 發送(模式由 `Ses__SendMode` 決定) - SES 相關參數:`Ses__Region`、`Ses__FromEmail`、`Ses__ConfigurationSet`(可選)、`Ses__SendMode`、`Ses__TemplateName` + - SNS 簽章驗證參數: + - `Ses__SkipSignatureValidation`(建議正式環境 `false`) + - `Ses__AllowedTopicArns`(逗號分隔 allowlist;建議正式環境必填) + - `Ses__AllowedCertHosts`(逗號分隔;留空時只接受 `sns.*.amazonaws.com`) + - `Ses__SignatureMaxSkewSeconds`(預設 300) - `Ses__SendMode=raw_bulk`(預設):使用 SES `SendEmail`,依內容分組後每次最多 50 位收件者(不依賴 SES Template) - `Ses__SendMode=bulk_template`:使用 SES `SendBulkEmail` + Template(需提供 `template.ses_template_name` 或 `Ses__TemplateName`) - SES 發送時會附帶 message tags:`tenant_id`、`list_id`、`campaign_id`、`send_job_id` diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 1b173e7..aad8566 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -291,8 +291,8 @@ Response: - `POST /webhooks/ses` 驗證: -- 目前實作:`Ses__SkipSignatureValidation=false` 時僅要求 `X-Amz-Sns-Signature` header 存在 -- 正式建議:補上 SES/SNS 憑證鏈與簽章內容驗證 +- 目前實作:`Ses__SkipSignatureValidation=false` 時會驗 SNS envelope 簽章(`SigningCertURL`、`SignatureVersion`、`Signature`、canonical string) +- 可加強:在環境設定 `Ses__AllowedTopicArns` 與 `Ses__AllowedCertHosts` 做來源白名單 Request Body(示意): ```json diff --git a/src/SendEngine.Api/Services/SesEventProcessingService.cs b/src/SendEngine.Api/Services/SesEventProcessingService.cs index 6c4ae5e..058ea4b 100644 --- a/src/SendEngine.Api/Services/SesEventProcessingService.cs +++ b/src/SendEngine.Api/Services/SesEventProcessingService.cs @@ -1,5 +1,9 @@ using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json; +using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; using SendEngine.Api.Models; using SendEngine.Domain.Entities; @@ -9,6 +13,8 @@ namespace SendEngine.Api.Services; public sealed class SesEventProcessingService { + private static readonly HttpClient CertHttpClient = new(); + private static readonly ConcurrentDictionary CertCache = new(StringComparer.Ordinal); private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; @@ -50,18 +56,19 @@ public sealed class SesEventProcessingService string? snsSignature, CancellationToken cancellationToken) { - if (!TryNormalizeSesEventRequest(body, out var request, out var parseError)) + if (!TryNormalizeSesEventRequest(body, out var parsed, out var parseError)) { if (parseError.StartsWith("unsupported_sns_type:", StringComparison.Ordinal)) { _logger.LogInformation("SES webhook ignored non-notification SNS message. reason={Reason}", parseError); - return SesProcessResult.Ok(200, "ignored", parseError); + return SesProcessResult.Ok(200, "ignored", parseError); } _logger.LogWarning("SES webhook rejected: invalid_payload. reason={Reason}", parseError); return SesProcessResult.Permanent(422, "invalid_payload", parseError); } + var request = parsed.Request; if (request.TenantId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email)) { _logger.LogWarning("SES webhook rejected: tenant_id or email missing."); @@ -70,10 +77,34 @@ public sealed class SesEventProcessingService var skipValidation = _configuration.GetValue("Ses:SkipSignatureValidation", true); _logger.LogInformation("SES webhook received. Ses__SkipSignatureValidation={SkipValidation}", skipValidation); - if (!skipValidation && string.IsNullOrWhiteSpace(snsSignature)) + if (!skipValidation) { - _logger.LogWarning("SES webhook rejected: missing X-Amz-Sns-Signature while signature validation is enabled."); - return SesProcessResult.Permanent(401, "unauthorized", "missing_signature"); + var signatureSource = parsed.SnsEnvelope?.Signature; + if (string.IsNullOrWhiteSpace(signatureSource)) + { + signatureSource = snsSignature; + } + + if (parsed.SnsEnvelope is null) + { + _logger.LogWarning("SES webhook rejected: signature validation requires SNS envelope payload."); + return SesProcessResult.Permanent(401, "unauthorized", "signature_requires_sns_envelope"); + } + if (string.IsNullOrWhiteSpace(signatureSource)) + { + _logger.LogWarning("SES webhook rejected: missing SNS signature while signature validation is enabled."); + return SesProcessResult.Permanent(401, "unauthorized", "missing_signature"); + } + + var signatureValidation = await ValidateSnsSignatureAsync(parsed.SnsEnvelope, signatureSource, cancellationToken); + if (!signatureValidation.Success) + { + _logger.LogWarning( + "SES webhook rejected: SNS signature validation failed. reason={Reason} topic_arn={TopicArn}", + signatureValidation.Reason, + parsed.SnsEnvelope.TopicArn); + return SesProcessResult.Permanent(401, "unauthorized", signatureValidation.Reason); + } } var normalizedEventType = NormalizeSesEventType(request.EventType, request.BounceType); @@ -509,16 +540,17 @@ public sealed class SesEventProcessingService : true; } - private static bool TryNormalizeSesEventRequest(JsonElement body, out SesEventRequest request, out string error) + private static bool TryNormalizeSesEventRequest(JsonElement body, out ParsedSesPayload parsed, out string error) { - request = new SesEventRequest(); + parsed = new ParsedSesPayload(new SesEventRequest(), null); error = string.Empty; if (body.TryGetProperty("tenant_id", out _)) { try { - request = JsonSerializer.Deserialize(body.GetRawText()) ?? new SesEventRequest(); + var request = JsonSerializer.Deserialize(body.GetRawText()) ?? new SesEventRequest(); + parsed = new ParsedSesPayload(request, null); return true; } catch (JsonException ex) @@ -601,7 +633,7 @@ public sealed class SesEventProcessingService tenantId = parsedTenant; } - request = new SesEventRequest + var request = new SesEventRequest { EventType = eventType, MessageId = messageId, @@ -611,11 +643,145 @@ public sealed class SesEventProcessingService OccurredAt = occurredAt, Tags = tags }; + var envelope = new SnsEnvelopePayload( + Type: snsType, + Message: messageJson, + MessageId: TryGetString(body, "MessageId") ?? string.Empty, + Subject: TryGetString(body, "Subject"), + Timestamp: TryGetString(body, "Timestamp") ?? string.Empty, + TopicArn: TryGetString(body, "TopicArn") ?? string.Empty, + SignatureVersion: TryGetString(body, "SignatureVersion") ?? string.Empty, + Signature: TryGetString(body, "Signature") ?? string.Empty, + SigningCertUrl: TryGetString(body, "SigningCertURL") ?? string.Empty); + parsed = new ParsedSesPayload(request, envelope); return true; } } + private async Task<(bool Success, string Reason)> ValidateSnsSignatureAsync( + SnsEnvelopePayload envelope, + string signature, + CancellationToken cancellationToken) + { + var allowedTopicArns = (_configuration["Ses:AllowedTopicArns"] ?? string.Empty) + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (allowedTopicArns.Length > 0 && + !allowedTopicArns.Contains(envelope.TopicArn, StringComparer.Ordinal)) + { + return (false, "topic_arn_not_allowed"); + } + + if (!DateTimeOffset.TryParse(envelope.Timestamp, out var messageTimestamp)) + { + return (false, "invalid_timestamp"); + } + var maxSkewSeconds = Math.Max(1, _configuration.GetValue("Ses:SignatureMaxSkewSeconds", 300)); + if (Math.Abs((DateTimeOffset.UtcNow - messageTimestamp).TotalSeconds) > maxSkewSeconds) + { + return (false, "timestamp_out_of_skew"); + } + + if (!Uri.TryCreate(envelope.SigningCertUrl, UriKind.Absolute, out var certUri) || + !string.Equals(certUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return (false, "invalid_signing_cert_url"); + } + + var allowedCertHosts = (_configuration["Ses:AllowedCertHosts"] ?? string.Empty) + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var certHostAllowed = allowedCertHosts.Length > 0 + ? allowedCertHosts.Contains(certUri.Host, StringComparer.OrdinalIgnoreCase) + : certUri.Host.StartsWith("sns.", StringComparison.OrdinalIgnoreCase) && + certUri.Host.EndsWith(".amazonaws.com", StringComparison.OrdinalIgnoreCase); + if (!certHostAllowed) + { + return (false, "signing_cert_host_not_allowed"); + } + + X509Certificate2 cert; + try + { + cert = await GetSigningCertificateAsync(certUri.ToString(), cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SNS signing cert fetch failed."); + return (false, "signing_cert_fetch_failed"); + } + + var canonical = BuildNotificationCanonicalString(envelope); + byte[] signatureBytes; + try + { + signatureBytes = Convert.FromBase64String(signature); + } + catch + { + return (false, "invalid_signature_base64"); + } + + var hashAlgorithm = envelope.SignatureVersion switch + { + "1" => HashAlgorithmName.SHA1, + "2" => HashAlgorithmName.SHA256, + _ => default + }; + if (hashAlgorithm == default) + { + return (false, "unsupported_signature_version"); + } + + using var rsa = cert.GetRSAPublicKey(); + if (rsa is null) + { + return (false, "invalid_signing_cert_public_key"); + } + + var verified = rsa.VerifyData( + Encoding.UTF8.GetBytes(canonical), + signatureBytes, + hashAlgorithm, + RSASignaturePadding.Pkcs1); + return verified ? (true, "ok") : (false, "invalid_signature"); + } + + private static string BuildNotificationCanonicalString(SnsEnvelopePayload envelope) + { + var sb = new StringBuilder(); + Append("Message", envelope.Message); + Append("MessageId", envelope.MessageId); + if (!string.IsNullOrWhiteSpace(envelope.Subject)) + { + Append("Subject", envelope.Subject); + } + Append("Timestamp", envelope.Timestamp); + Append("TopicArn", envelope.TopicArn); + Append("Type", envelope.Type); + return sb.ToString(); + + void Append(string key, string? value) + { + sb.Append(key).Append('\n').Append(value ?? string.Empty).Append('\n'); + } + } + + private static async Task GetSigningCertificateAsync(string certUrl, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + if (CertCache.TryGetValue(certUrl, out var cached) && + now - cached.CachedAt < TimeSpan.FromHours(6)) + { + return cached.Cert; + } + + var pem = await CertHttpClient.GetStringAsync(certUrl, cancellationToken); + var cert = X509Certificate2.CreateFromPem(pem); + var stored = new X509Certificate2(cert.Export(X509ContentType.Cert)); + CertCache[certUrl] = (now, stored); + return stored; + } + private static string? ResolveBounceType(JsonElement root, string eventType) { if (!string.Equals(eventType, "Bounce", StringComparison.OrdinalIgnoreCase)) @@ -792,6 +958,18 @@ public sealed class SesEventProcessingService } } +public sealed record ParsedSesPayload(SesEventRequest Request, SnsEnvelopePayload? SnsEnvelope); +public sealed record SnsEnvelopePayload( + string Type, + string Message, + string MessageId, + string? Subject, + string Timestamp, + string TopicArn, + string SignatureVersion, + string Signature, + string SigningCertUrl); + public sealed record SesProcessResult( bool Success, bool TransientFailure,