refinement phase 5
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Baya.Web.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast validation that no load-bearing secret is missing or left at its committed placeholder.
|
||||
/// A database connection is required in every real environment; the JWE + field-encryption keys are
|
||||
/// required only in <b>deployed</b> environments (Development keeps working dev-only defaults in
|
||||
/// <c>appsettings.Development.json</c>, and the "Testing" environment runs on in-memory SQLite with
|
||||
/// test-injected keys). The effect: a fresh clone with no user-secrets stops at boot with a clear
|
||||
/// message instead of silently connecting somewhere unintended, and a deployment can never fall back
|
||||
/// to a committed placeholder key.
|
||||
/// </summary>
|
||||
public static class StartupSecretsGuard
|
||||
{
|
||||
// Substrings that mark a value as a committed placeholder, never a real secret. Any configured value
|
||||
// containing one of these is treated as "not provided".
|
||||
private static readonly string[] PlaceholderMarkers =
|
||||
[
|
||||
"SET_VIA_USER_SECRETS_OR_ENV",
|
||||
"not-for-production",
|
||||
"change-me",
|
||||
"ShouldBe-LongerThan-16Char-SecretKey",
|
||||
"16CharEncryptKey"
|
||||
];
|
||||
|
||||
public static void ValidateRequiredSecrets(this WebApplicationBuilder builder)
|
||||
{
|
||||
// Integration tests boot as "Testing" over in-memory SQLite and inject their own crypto keys.
|
||||
if (builder.Environment.IsEnvironment("Testing"))
|
||||
return;
|
||||
|
||||
var config = builder.Configuration;
|
||||
var errors = new List<string>();
|
||||
|
||||
RequireReal(errors, "ConnectionStrings:SqlServer", config.GetConnectionString("SqlServer"));
|
||||
RequireReal(errors, "ConnectionStrings:logDb", config.GetConnectionString("logDb"));
|
||||
|
||||
// Development supplies working dev-only keys via appsettings.Development.json; only deployed
|
||||
// environments must inject real per-environment secrets (env vars / Key Vault / KMS).
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
RequireReal(errors, "IdentitySettings:SecretKey", config["IdentitySettings:SecretKey"]);
|
||||
RequireReal(errors, "IdentitySettings:Encryptkey", config["IdentitySettings:Encryptkey"]);
|
||||
RequireReal(errors, "Seams:FieldEncryption:Key", config["Seams:FieldEncryption:Key"]);
|
||||
RequireReal(errors, "Seams:FieldEncryption:HashKey", config["Seams:FieldEncryption:HashKey"]);
|
||||
}
|
||||
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Refusing to start: required secret configuration is missing or still a committed placeholder. " +
|
||||
"Provide real values via user-secrets (Development) or environment variables (deployed) — see " +
|
||||
"dev/post-phase/refinement/RUNBOOK.md.\n - " + string.Join("\n - ", errors));
|
||||
}
|
||||
|
||||
private static void RequireReal(List<string> errors, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
errors.Add($"{key} is not set.");
|
||||
else if (PlaceholderMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add($"{key} is still a committed placeholder.");
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/webhooks_bnpl")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
|
||||
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
|
||||
public sealed class WebhooksBnplController(ISender sender) : BaseController
|
||||
{
|
||||
|
||||
@@ -7,9 +7,11 @@ using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Baya.WebFramework.ServiceConfiguration;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
@@ -23,6 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/webhooks")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
|
||||
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
|
||||
public sealed class WebhooksController(ISender sender) : BaseController
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ using Baya.Infrastructure.Identity.ServiceConfiguration;
|
||||
using Baya.Infrastructure.Monitoring.Configurations;
|
||||
using Baya.Infrastructure.Persistence.ServiceConfiguration;
|
||||
using Baya.SharedKernel.Extensions;
|
||||
using Baya.Web.Api.Configuration;
|
||||
using Baya.Web.Plugins.Grpc;
|
||||
using Baya.WebFramework.Filters;
|
||||
using Baya.WebFramework.Middlewares;
|
||||
@@ -29,8 +30,16 @@ builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger);
|
||||
|
||||
var configuration = builder.Configuration;
|
||||
|
||||
// Fail fast if a load-bearing secret (DB connection, JWE/field-encryption keys) is missing or still a
|
||||
// committed placeholder — before any service reaches for it. Skipped in the "Testing" environment.
|
||||
builder.ValidateRequiredSecrets();
|
||||
|
||||
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
|
||||
|
||||
// HTTPS metadata is required for the token exchange in deployed environments; relaxed for local
|
||||
// Development and the Testing host, which run over plain HTTP.
|
||||
var requireHttpsMetadata = !builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing");
|
||||
|
||||
builder
|
||||
.ConfigureHealthChecks()
|
||||
.SetupOpenTelemetry();
|
||||
@@ -68,11 +77,12 @@ builder.Services.AddSwagger("v1","v1.1");
|
||||
|
||||
|
||||
builder.Services.AddApplicationServices()
|
||||
.RegisterIdentityServices(identitySettings)
|
||||
.RegisterIdentityServices(identitySettings, requireHttpsMetadata)
|
||||
.AddPersistenceServices(configuration)
|
||||
.AddCrossCuttingSeams(configuration)
|
||||
.AddWebFrameworkServices()
|
||||
.AddCorsPolicies(configuration)
|
||||
.AddForwardedHeadersConfiguration(configuration)
|
||||
.AddRateLimitingPolicies();
|
||||
|
||||
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
|
||||
@@ -106,12 +116,16 @@ if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
|
||||
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients)
|
||||
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging.
|
||||
// Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
|
||||
// (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither
|
||||
// belongs in a deployed DB — a production gateway is an admin action, so this never runs in
|
||||
// Production/Staging. Both are idempotent.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
await app.SeedDemoWorldAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -121,6 +135,10 @@ if (app.Environment.IsDevelopment())
|
||||
else
|
||||
app.UseExceptionHandler(_=>{});
|
||||
|
||||
// First in the pipeline so the resolved client IP (X-Forwarded-For, from a trusted proxy) is in place
|
||||
// before anything downstream — notably the rate limiter — reads HttpContext.Connection.RemoteIpAddress.
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseSwaggerAndUi();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
@@ -1,37 +1,15 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"IdentitySettings": {
|
||||
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
|
||||
"Encryptkey": "16CharEncryptKey",
|
||||
"Issuer": "MyWebsite",
|
||||
"Audience": "MyWebsite",
|
||||
"NotBeforeMinutes": "0",
|
||||
"ExpirationMinutes": "10000"
|
||||
"SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef",
|
||||
"Encryptkey": "dev-only-16bytes"
|
||||
},
|
||||
"Seams": {
|
||||
"FieldEncryption": {
|
||||
"Key": "local-dev-field-encryption-key-change-me",
|
||||
"HashKey": "local-dev-field-hash-key-change-me"
|
||||
},
|
||||
"ObjectStorage": {
|
||||
"RootPath": ""
|
||||
},
|
||||
"Geocoding": {
|
||||
"ReturnNullCoordinates": false,
|
||||
"LowConfidenceMarker": "NO_GEO",
|
||||
"ResolvedConfidence": 0.9
|
||||
"Key": "local-dev-field-encryption-key-not-for-production",
|
||||
"HashKey": "local-dev-field-hash-key-not-for-production"
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:3000" ]
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
"Protocols": "Http2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"IdentitySettings": {
|
||||
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
|
||||
"Encryptkey": "16CharEncryptKey",
|
||||
"Issuer": "MyWebsite",
|
||||
"Audience": "MyWebsite",
|
||||
"SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||
"Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||
"Issuer": "Balinyaar",
|
||||
"Audience": "BalinyaarClient",
|
||||
"NotBeforeMinutes": "0",
|
||||
"ExpirationMinutes": "10000"
|
||||
"ExpirationMinutes": "60"
|
||||
},
|
||||
"Seams": {
|
||||
"FieldEncryption": {
|
||||
"Key": "local-dev-field-encryption-key-change-me",
|
||||
"HashKey": "local-dev-field-hash-key-change-me"
|
||||
"Key": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||
"HashKey": "SET_VIA_USER_SECRETS_OR_ENV"
|
||||
},
|
||||
"ObjectStorage": {
|
||||
"RootPath": ""
|
||||
@@ -28,10 +28,14 @@
|
||||
"Cors": {
|
||||
"AllowedOrigins": []
|
||||
},
|
||||
"ForwardedHeaders": {
|
||||
"KnownProxies": [],
|
||||
"KnownNetworks": []
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
"Protocols": "Http2"
|
||||
"Protocols": "Http1AndHttp2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Baya.WebFramework.ServiceConfiguration;
|
||||
|
||||
public static class ForwardedHeadersServiceExtension
|
||||
{
|
||||
/// <summary>Configuration section holding the trusted reverse-proxy addresses/networks.</summary>
|
||||
public const string ConfigSection = "ForwardedHeaders";
|
||||
|
||||
/// <summary>
|
||||
/// Configures the forwarded-headers middleware so that, behind a reverse proxy, the resolved client
|
||||
/// address (from <c>X-Forwarded-For</c>) — not the proxy's address — is what
|
||||
/// <c>HttpContext.Connection.RemoteIpAddress</c> reports. The rate limiter partitions on that address,
|
||||
/// so without this every client behind the proxy shares a single bucket (self-DoS). Only proxies listed
|
||||
/// in <c>ForwardedHeaders:KnownProxies</c> / <c>:KnownNetworks</c> (plus loopback) are trusted; an
|
||||
/// untrusted hop's forwarded header is ignored, so a client can't spoof its address. Pair with
|
||||
/// <c>app.UseForwardedHeaders()</c> placed first in the pipeline (before anything reads the client IP).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddForwardedHeadersConfiguration(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var knownProxies = configuration.GetSection($"{ConfigSection}:KnownProxies").Get<string[]>() ?? [];
|
||||
var knownNetworks = configuration.GetSection($"{ConfigSection}:KnownNetworks").Get<string[]>() ?? [];
|
||||
|
||||
services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
|
||||
// The framework defaults trust only loopback; extend that to the deployment's real proxy hop(s).
|
||||
foreach (var proxy in knownProxies)
|
||||
if (IPAddress.TryParse(proxy, out var address))
|
||||
options.KnownProxies.Add(address);
|
||||
|
||||
foreach (var network in knownNetworks)
|
||||
{
|
||||
var parts = network.Split('/', 2);
|
||||
if (parts.Length == 2 && IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var prefixLength))
|
||||
options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength));
|
||||
}
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -20,6 +20,13 @@ public static class RateLimitingServiceExtension
|
||||
/// <summary>Limit for money-sensitive actions (refund/payout) applied in later phases.</summary>
|
||||
public const string SensitivePolicy = "sensitive";
|
||||
|
||||
/// <summary>
|
||||
/// The single deliberate policy for inbound PSP/BNPL webhooks. PSP callbacks are bursty (retries,
|
||||
/// batched settlements), so this is more permissive than <see cref="SensitivePolicy"/> and partitions
|
||||
/// per-provider (not just per-IP) so one provider's burst can't starve another.
|
||||
/// </summary>
|
||||
public const string WebhookPolicy = "webhook";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the built-in rate limiter with a per-IP global limit plus named policies that
|
||||
/// auth/OTP/sensitive endpoints opt into via <c>[EnableRateLimiting(name)]</c>. Over-limit
|
||||
@@ -46,6 +53,9 @@ public static class RateLimitingServiceExtension
|
||||
AddFixedWindowPolicy(options, AuthPolicy, permitLimit: 10, windowSeconds: 60);
|
||||
AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, windowSeconds: 60);
|
||||
|
||||
// Bursty PSP/BNPL callbacks, partitioned per-provider (see WebhookPartitionKey).
|
||||
AddFixedWindowPolicy(options, WebhookPolicy, permitLimit: 120, windowSeconds: 60, keyResolver: WebhookPartitionKey);
|
||||
|
||||
// A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s.
|
||||
AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10);
|
||||
});
|
||||
@@ -53,11 +63,13 @@ public static class RateLimitingServiceExtension
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds)
|
||||
private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds,
|
||||
Func<HttpContext, string>? keyResolver = null)
|
||||
{
|
||||
var resolvePartition = keyResolver ?? PartitionKey;
|
||||
options.AddPolicy(name, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
PartitionKey(context),
|
||||
resolvePartition(context),
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = permitLimit,
|
||||
@@ -66,6 +78,16 @@ public static class RateLimitingServiceExtension
|
||||
}));
|
||||
}
|
||||
|
||||
// The client IP as resolved by the forwarded-headers middleware (see ForwardedHeadersServiceExtension) —
|
||||
// behind a trusted proxy this is the real client, not the proxy, so each client gets its own bucket.
|
||||
private static string PartitionKey(HttpContext context) =>
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
// Per-provider partition so a single PSP's burst can't exhaust the shared webhook budget; still bounded
|
||||
// by the resolved client IP so a spoofed provider segment can't fan out unboundedly.
|
||||
private static string WebhookPartitionKey(HttpContext context)
|
||||
{
|
||||
var provider = context.Request.RouteValues.TryGetValue("provider", out var value) ? value?.ToString() : null;
|
||||
return $"webhook:{(string.IsNullOrWhiteSpace(provider) ? "unknown" : provider)}:{PartitionKey(context)}";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user