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