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__TimestampSkewSeconds=300
|
||||||
Webhook__AllowNullTenantClient=false
|
Webhook__AllowNullTenantClient=false
|
||||||
Ses__SkipSignatureValidation=true
|
Ses__SkipSignatureValidation=true
|
||||||
|
Ses__AllowedTopicArns=
|
||||||
|
Ses__AllowedCertHosts=
|
||||||
|
Ses__SignatureMaxSkewSeconds=300
|
||||||
Bounce__SoftBounceThreshold=5
|
Bounce__SoftBounceThreshold=5
|
||||||
MemberCenter__BaseUrl=
|
MemberCenter__BaseUrl=
|
||||||
MemberCenter__DisableSubscriptionPath=/subscriptions/disable
|
MemberCenter__DisableSubscriptionPath=/subscriptions/disable
|
||||||
|
|||||||
@ -65,6 +65,11 @@
|
|||||||
- `DevSender__PollIntervalSeconds`:輪詢間隔秒數(預設 5)
|
- `DevSender__PollIntervalSeconds`:輪詢間隔秒數(預設 5)
|
||||||
- `ESP__Provider=ses` 時,即使 `DevSender__Enabled=false`,背景 sender 仍會啟動並改用 SES 發送(模式由 `Ses__SendMode` 決定)
|
- `ESP__Provider=ses` 時,即使 `DevSender__Enabled=false`,背景 sender 仍會啟動並改用 SES 發送(模式由 `Ses__SendMode` 決定)
|
||||||
- SES 相關參數:`Ses__Region`、`Ses__FromEmail`、`Ses__ConfigurationSet`(可選)、`Ses__SendMode`、`Ses__TemplateName`
|
- 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=raw_bulk`(預設):使用 SES `SendEmail`,依內容分組後每次最多 50 位收件者(不依賴 SES Template)
|
||||||
- `Ses__SendMode=bulk_template`:使用 SES `SendBulkEmail` + Template(需提供 `template.ses_template_name` 或 `Ses__TemplateName`)
|
- `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`
|
- SES 發送時會附帶 message tags:`tenant_id`、`list_id`、`campaign_id`、`send_job_id`
|
||||||
|
|||||||
@ -291,8 +291,8 @@ Response:
|
|||||||
- `POST /webhooks/ses`
|
- `POST /webhooks/ses`
|
||||||
|
|
||||||
驗證:
|
驗證:
|
||||||
- 目前實作:`Ses__SkipSignatureValidation=false` 時僅要求 `X-Amz-Sns-Signature` header 存在
|
- 目前實作:`Ses__SkipSignatureValidation=false` 時會驗 SNS envelope 簽章(`SigningCertURL`、`SignatureVersion`、`Signature`、canonical string)
|
||||||
- 正式建議:補上 SES/SNS 憑證鏈與簽章內容驗證
|
- 可加強:在環境設定 `Ses__AllowedTopicArns` 與 `Ses__AllowedCertHosts` 做來源白名單
|
||||||
|
|
||||||
Request Body(示意):
|
Request Body(示意):
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SendEngine.Api.Models;
|
using SendEngine.Api.Models;
|
||||||
using SendEngine.Domain.Entities;
|
using SendEngine.Domain.Entities;
|
||||||
@ -9,6 +13,8 @@ namespace SendEngine.Api.Services;
|
|||||||
|
|
||||||
public sealed class SesEventProcessingService
|
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 IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<SesEventProcessingService> _logger;
|
private readonly ILogger<SesEventProcessingService> _logger;
|
||||||
@ -50,7 +56,7 @@ public sealed class SesEventProcessingService
|
|||||||
string? snsSignature,
|
string? snsSignature,
|
||||||
CancellationToken cancellationToken)
|
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))
|
if (parseError.StartsWith("unsupported_sns_type:", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
@ -62,6 +68,7 @@ public sealed class SesEventProcessingService
|
|||||||
return SesProcessResult.Permanent(422, "invalid_payload", parseError);
|
return SesProcessResult.Permanent(422, "invalid_payload", parseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var request = parsed.Request;
|
||||||
if (request.TenantId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
|
if (request.TenantId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SES webhook rejected: tenant_id or email missing.");
|
_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);
|
var skipValidation = _configuration.GetValue("Ses:SkipSignatureValidation", true);
|
||||||
_logger.LogInformation("SES webhook received. Ses__SkipSignatureValidation={SkipValidation}", skipValidation);
|
_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");
|
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);
|
var normalizedEventType = NormalizeSesEventType(request.EventType, request.BounceType);
|
||||||
request.Email = request.Email.Trim().ToLowerInvariant();
|
request.Email = request.Email.Trim().ToLowerInvariant();
|
||||||
request.EventType = normalizedEventType;
|
request.EventType = normalizedEventType;
|
||||||
@ -509,16 +540,17 @@ public sealed class SesEventProcessingService
|
|||||||
: true;
|
: 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;
|
error = string.Empty;
|
||||||
|
|
||||||
if (body.TryGetProperty("tenant_id", out _))
|
if (body.TryGetProperty("tenant_id", out _))
|
||||||
{
|
{
|
||||||
try
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@ -601,7 +633,7 @@ public sealed class SesEventProcessingService
|
|||||||
tenantId = parsedTenant;
|
tenantId = parsedTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
request = new SesEventRequest
|
var request = new SesEventRequest
|
||||||
{
|
{
|
||||||
EventType = eventType,
|
EventType = eventType,
|
||||||
MessageId = messageId,
|
MessageId = messageId,
|
||||||
@ -611,11 +643,145 @@ public sealed class SesEventProcessingService
|
|||||||
OccurredAt = occurredAt,
|
OccurredAt = occurredAt,
|
||||||
Tags = tags
|
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;
|
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)
|
private static string? ResolveBounceType(JsonElement root, string eventType)
|
||||||
{
|
{
|
||||||
if (!string.Equals(eventType, "Bounce", StringComparison.OrdinalIgnoreCase))
|
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(
|
public sealed record SesProcessResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
bool TransientFailure,
|
bool TransientFailure,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user