9fa16b650d
- POST /api/contact: validate -> honeypot -> per-IP rate limit -> SMTP send - /healthz endpoint; CORS locked to site origin; forwarded-headers for real client IP - no persistence, no message bodies logged (IP hashed to a short non-reversible marker) - source-generated JSON; config via env (SMTP + relay settings) - alpine multi-stage Dockerfile, non-root, healthcheck - verified: healthz 200, invalid 400, honeypot silent 200, missing-SMTP 502 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
4.9 KiB
C#
147 lines
4.9 KiB
C#
using System.Net;
|
|
using System.Net.Mail;
|
|
using System.Net.Mime;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
|
|
// Stateless contact relay (TECH_SPEC §7): validate -> honeypot -> rate-limit ->
|
|
// SMTP send -> forget. No persistence, no message bodies logged.
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var cfg = builder.Configuration;
|
|
var allowedOrigin = cfg["Relay:AllowedOrigin"] ?? "https://cesnimda.co.uk";
|
|
var rateLimit = cfg.GetValue("Relay:RateLimitPerWindow", 5);
|
|
var windowSeconds = cfg.GetValue("Relay:WindowSeconds", 600);
|
|
|
|
builder.Services.ConfigureHttpJsonOptions(o =>
|
|
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
|
|
|
|
// Real client IP from our own reverse proxy.
|
|
builder.Services.Configure<ForwardedHeadersOptions>(o =>
|
|
{
|
|
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
|
|
o.KnownNetworks.Clear();
|
|
o.KnownProxies.Clear();
|
|
});
|
|
|
|
builder.Services.AddCors(o => o.AddPolicy("site", p =>
|
|
p.WithOrigins(allowedOrigin).WithMethods("POST").WithHeaders("Content-Type")));
|
|
|
|
builder.Services.AddRateLimiter(o =>
|
|
{
|
|
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
o.AddPolicy("contact", ctx => RateLimitPartition.GetFixedWindowLimiter(
|
|
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = rateLimit,
|
|
Window = TimeSpan.FromSeconds(windowSeconds),
|
|
QueueLimit = 0,
|
|
}));
|
|
});
|
|
|
|
var app = builder.Build();
|
|
app.UseForwardedHeaders();
|
|
app.UseCors("site");
|
|
app.UseRateLimiter();
|
|
|
|
app.MapGet("/healthz", () => Results.Ok("ok"));
|
|
|
|
app.MapPost("/api/contact", async (ContactRequest req, HttpContext http, ILogger<Program> log) =>
|
|
{
|
|
var ipHash = HashIp(http.Connection.RemoteIpAddress?.ToString());
|
|
|
|
// Honeypot: a filled hidden field means a bot. Feign success, send nothing.
|
|
if (!string.IsNullOrWhiteSpace(req.Company))
|
|
{
|
|
log.LogInformation("contact rejected (honeypot) ip={Ip}", ipHash);
|
|
return Results.Ok(new ContactResponse(true));
|
|
}
|
|
|
|
var name = (req.Name ?? "").Trim();
|
|
var email = (req.Email ?? "").Trim();
|
|
var message = (req.Message ?? "").Trim();
|
|
|
|
if (name.Length is 0 or > 200 ||
|
|
email.Length is 0 or > 320 || !IsEmail(email) ||
|
|
message.Length is 0 or > 5000)
|
|
{
|
|
return Results.BadRequest(new ContactResponse(false, "invalid"));
|
|
}
|
|
|
|
try
|
|
{
|
|
await SendAsync(cfg, name, email, message);
|
|
log.LogInformation("contact sent ip={Ip}", ipHash);
|
|
return Results.Ok(new ContactResponse(true));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.LogError(ex, "contact send failed ip={Ip}", ipHash);
|
|
return Results.StatusCode(StatusCodes.Status502BadGateway);
|
|
}
|
|
}).RequireRateLimiting("contact");
|
|
|
|
app.Run();
|
|
|
|
static bool IsEmail(string s)
|
|
{
|
|
var at = s.IndexOf('@');
|
|
var dot = s.LastIndexOf('.');
|
|
return at > 0 && dot > at + 1 && dot < s.Length - 1 && !s.Contains(' ');
|
|
}
|
|
|
|
static string HashIp(string? ip)
|
|
{
|
|
if (string.IsNullOrEmpty(ip)) return "unknown";
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ip));
|
|
return Convert.ToHexString(bytes, 0, 4); // short, non-reversible marker
|
|
}
|
|
|
|
static async Task SendAsync(IConfiguration cfg, string name, string email, string message)
|
|
{
|
|
var host = cfg["Smtp:Host"] ?? throw new InvalidOperationException("Smtp:Host not configured");
|
|
var port = cfg.GetValue("Smtp:Port", 587);
|
|
var user = cfg["Smtp:User"];
|
|
var password = cfg["Smtp:Password"];
|
|
var to = cfg["Relay:ToAddress"] ?? throw new InvalidOperationException("Relay:ToAddress not configured");
|
|
var from = cfg["Relay:FromAddress"] ?? user ?? to;
|
|
|
|
using var msg = new MailMessage
|
|
{
|
|
From = new MailAddress(from, "Portfolio contact form"),
|
|
Subject = $"Portfolio contact from {name}",
|
|
Body =
|
|
$"From: {name} <{email}>\n\n{message}\n\n— sent via cesnimda.co.uk contact form",
|
|
BodyEncoding = Encoding.UTF8,
|
|
SubjectEncoding = Encoding.UTF8,
|
|
};
|
|
msg.To.Add(to);
|
|
msg.ReplyToList.Add(new MailAddress(email, name));
|
|
|
|
using var client = new SmtpClient(host, port)
|
|
{
|
|
EnableSsl = true,
|
|
DeliveryMethod = SmtpDeliveryMethod.Network,
|
|
Credentials = string.IsNullOrEmpty(user) ? null : new NetworkCredential(user, password),
|
|
};
|
|
await client.SendMailAsync(msg);
|
|
}
|
|
|
|
record ContactRequest(
|
|
[property: JsonPropertyName("name")] string? Name,
|
|
[property: JsonPropertyName("email")] string? Email,
|
|
[property: JsonPropertyName("message")] string? Message,
|
|
[property: JsonPropertyName("company")] string? Company);
|
|
|
|
record ContactResponse(bool Ok, string? Error = null);
|
|
|
|
[JsonSerializable(typeof(ContactRequest))]
|
|
[JsonSerializable(typeof(ContactResponse))]
|
|
partial class AppJsonContext : JsonSerializerContext;
|