Files
cesnimda 9fa16b650d feat: .NET 9 contact relay (stateless minimal API)
- 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>
2026-07-04 05:55:20 +02:00

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;