626a9f8454
Backend security fixes from the Phase 1 register / Phase 2 roadmap (PR1 + V-01): - V-01 SSRF: new SafeHttpGuard validates outbound unsubscribe URLs (scheme allowlist + DNS-resolve-and-block private/loopback/link-local/ULA/metadata ranges), wired into UnsubscribeService; the "unsubscribe" HttpClient now disables auto-redirect so a validated external URL can't 3xx into an internal target. +33 unit tests. - V-04: session cookie SecurePolicy=Always in non-dev (SameAsRequest in dev). - V-06: UseExceptionHandler/ProblemDetails in prod; Cleanup/Unsubscribe no longer echo ex.Message to clients (logged server-side, generic message returned). - V-08: ForwardedHeaders trusted only from configurable KnownNetworks (default private ranges) + ForwardLimit, instead of trusting any client. New ForwardedHeaders config. - V-09: returnUrl validated with Url.IsLocalUrl (no open redirect via OAuth flow). - V-10: SearchService clamps Page/PageSize (<=200); Analytics clamps take/days. - V-11: baseline security headers (nosniff, X-Frame-Options DENY, Referrer-Policy, COOP) + HSTS in prod. - V-13: /app/info discloses only devMode to anonymous callers unless dev mode is on. - V-12: API container runs as non-root 'app' user (keys dir pre-owned). - V-03: Postgres + API ports bound to 127.0.0.1; POSTGRES_PASSWORD now required (no weak default fallback). API compatibility preserved (clamps not rejections; error-body shape changes only on failure paths). No DB migrations. Build + all 33 unit tests green. V-15 (MailKit NU1902) persists across versions and the SMTP path is default-off — tracked, not bumped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
7.4 KiB
C#
178 lines
7.4 KiB
C#
using System.Security.Claims;
|
|
using Asp.Versioning;
|
|
using InboxIntel.Api.Auth;
|
|
using InboxIntel.Application;
|
|
using InboxIntel.Application.Abstractions;
|
|
using InboxIntel.Infrastructure;
|
|
using InboxIntel.Infrastructure.Configuration;
|
|
using InboxIntel.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.Google;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Serilog;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Structured logging (Serilog). Note: token values are never logged.
|
|
builder.Host.UseSerilog((ctx, cfg) => cfg
|
|
.ReadFrom.Configuration(ctx.Configuration)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Console());
|
|
|
|
// Persist Data Protection keys so encrypted refresh tokens survive restarts.
|
|
builder.Services.AddDataProtection()
|
|
.PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys"))
|
|
.SetApplicationName("InboxIntel");
|
|
|
|
builder.Services.AddApplication();
|
|
builder.Services.AddInfrastructure(builder.Configuration);
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
|
|
|
// Authentication: cookie session established via Google OAuth2 (only login method).
|
|
var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Get<GoogleOAuthOptions>() ?? new();
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
// Challenge via the cookie scheme so unauthenticated API (XHR) calls get a
|
|
// 401 instead of a redirect to Google. The SPA's axios interceptor turns
|
|
// that 401 into a top-level navigation to /auth/login, which then starts
|
|
// the Google flow explicitly. (A 302 to Google on an XHR is CORS-blocked.)
|
|
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
})
|
|
.AddCookie(options =>
|
|
{
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
// V-04: never emit the session cookie over plain HTTP in non-dev. Behind nginx
|
|
// the forwarded proto (now only trusted from known proxies, see below) makes
|
|
// Always work; local http://localhost dev still functions via SameAsRequest.
|
|
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
|
|
? CookieSecurePolicy.SameAsRequest
|
|
: CookieSecurePolicy.Always;
|
|
options.Cookie.Name = "inboxintel.session";
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
|
options.SlidingExpiration = true;
|
|
// API-style behaviour: return status codes rather than redirecting to a login page.
|
|
options.Events.OnRedirectToLogin = ctx =>
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return Task.CompletedTask;
|
|
};
|
|
options.Events.OnRedirectToAccessDenied = ctx =>
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
return Task.CompletedTask;
|
|
};
|
|
})
|
|
.AddGoogle(options =>
|
|
{
|
|
options.ClientId = google.ClientId;
|
|
options.ClientSecret = google.ClientSecret;
|
|
options.AccessType = "offline"; // request a refresh token
|
|
options.SaveTokens = true;
|
|
foreach (var scope in google.Scopes) options.Scope.Add(scope);
|
|
options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync;
|
|
// Force the consent screen so Google ALWAYS returns a refresh token.
|
|
// Without this, Google omits the refresh token on re-authorisation,
|
|
// leaving offline Gmail sync with no usable credential.
|
|
options.Events.OnRedirectToAuthorizationEndpoint = context =>
|
|
{
|
|
context.Response.Redirect(context.RedirectUri + "&prompt=consent");
|
|
return Task.CompletedTask;
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
|
|
builder.Services.AddApiVersioning(o =>
|
|
{
|
|
o.DefaultApiVersion = new ApiVersion(1, 0);
|
|
o.AssumeDefaultVersionWhenUnspecified = true;
|
|
o.ReportApiVersions = true;
|
|
o.ApiVersionReader = new UrlSegmentApiVersionReader();
|
|
}).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; });
|
|
|
|
builder.Services.AddControllers();
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen();
|
|
// V-06: RFC7807 ProblemDetails so the global exception handler returns a safe,
|
|
// generic error body instead of leaking framework stack traces / internal messages.
|
|
builder.Services.AddProblemDetails();
|
|
|
|
builder.Services.AddCors(o => o.AddPolicy("frontend", p => p
|
|
.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? new[] { "http://localhost:5173" })
|
|
.AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
|
|
|
var app = builder.Build();
|
|
|
|
// Apply migrations on startup so `docker-compose up` yields a ready schema.
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true))
|
|
await db.Database.MigrateAsync();
|
|
}
|
|
|
|
// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and
|
|
// cookie Secure flags reflect the external scheme/host, not the container's.
|
|
// V-08: only trust these headers from KNOWN proxy networks (configurable). The
|
|
// default covers private/Docker ranges so the compose nginx works, while a client
|
|
// reaching the API directly can no longer spoof scheme/host/forwarded-for.
|
|
var forwardedOptions = new ForwardedHeadersOptions
|
|
{
|
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
|
|
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
|
|
};
|
|
forwardedOptions.KnownNetworks.Clear();
|
|
forwardedOptions.KnownProxies.Clear();
|
|
var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>()
|
|
?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" };
|
|
foreach (var cidr in trustedNetworks)
|
|
{
|
|
var parts = cidr.Split('/');
|
|
if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len))
|
|
forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len));
|
|
}
|
|
app.UseForwardedHeaders(forwardedOptions);
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
else
|
|
{
|
|
// V-06: generic ProblemDetails for unhandled exceptions (no stack traces to clients).
|
|
app.UseExceptionHandler();
|
|
// V-11: HSTS once TLS is enforced at the proxy (forwarded proto now trustworthy).
|
|
app.UseHsts();
|
|
}
|
|
|
|
// V-11: baseline security response headers. CSP is report-only for now so it can be
|
|
// tuned against the SPA before enforcing (the SPA itself is also served with headers
|
|
// by its nginx). Applied to API responses here as defense-in-depth.
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
var h = ctx.Response.Headers;
|
|
h["X-Content-Type-Options"] = "nosniff";
|
|
h["X-Frame-Options"] = "DENY";
|
|
h["Referrer-Policy"] = "no-referrer";
|
|
h["Cross-Origin-Opener-Policy"] = "same-origin";
|
|
await next();
|
|
});
|
|
|
|
app.UseSerilogRequestLogging();
|
|
app.UseCors("frontend");
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.MapControllers();
|
|
|
|
app.Run();
|
|
|
|
public partial class Program { }
|