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>
This commit is contained in:
cesnimda
2026-07-04 05:55:20 +02:00
parent 1b5204e7f8
commit 9fa16b650d
6 changed files with 212 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "site",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["-C", "site", "dev", "--host", "--port", "4321"],
"port": 4321
}
]
}
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
**/appsettings.*.local.json
Dockerfile
.dockerignore
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>ContactRelay</RootNamespace>
<AssemblyName>ContactRelay</AssemblyName>
</PropertyGroup>
</Project>
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# ---- build ----
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
WORKDIR /src
COPY ContactRelay.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
# ---- runtime ----
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app .
USER app
ENV ASPNETCORE_URLS=http://+:8081 \
DOTNET_EnableDiagnostics=0
EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:8081/healthz || exit 1
ENTRYPOINT ["dotnet", "ContactRelay.dll"]
+146
View File
@@ -0,0 +1,146 @@
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;
+16
View File
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Relay": {
"AllowedOrigin": "https://cesnimda.co.uk",
"RateLimitPerWindow": 5,
"WindowSeconds": 600
},
"Smtp": {
"Port": 587
}
}