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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "site",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["-C", "site", "dev", "--host", "--port", "4321"],
|
||||
"port": 4321
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
**/appsettings.*.local.json
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Relay": {
|
||||
"AllowedOrigin": "https://cesnimda.co.uk",
|
||||
"RateLimitPerWindow": 5,
|
||||
"WindowSeconds": 600
|
||||
},
|
||||
"Smtp": {
|
||||
"Port": 587
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user