feat: Enhance SES event processing with signature validation and configuration options
This commit is contained in:
parent
d49c30b447
commit
8b3f9284df
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,7 +56,7 @@ 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))
|
||||
{
|
||||
@ -62,6 +68,7 @@ public sealed class SesEventProcessingService
|
||||
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,12 +77,36 @@ 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.");
|
||||
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);
|
||||
request.Email = request.Email.Trim().ToLowerInvariant();
|
||||
request.EventType = normalizedEventType;
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user