feat: Enhance SES event processing with signature validation and configuration options

This commit is contained in:
warrenchen 2026-02-26 17:47:29 +09:00
parent d49c30b447
commit 8b3f9284df
4 changed files with 197 additions and 11 deletions

View File

@ -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

View File

@ -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`

View File

@ -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

View File

@ -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<string, (DateTimeOffset CachedAt, X509Certificate2 Cert)> CertCache = new(StringComparer.Ordinal);
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<SesEventProcessingService> _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<SesEventRequest>(body.GetRawText()) ?? new SesEventRequest();
var request = JsonSerializer.Deserialize<SesEventRequest>(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<X509Certificate2> 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,