From 1fe99c0109cce07e1ab1a82963057df809400e22 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 18 Jul 2026 14:52:25 +0100 Subject: [PATCH 1/2] Harden demo-ui security controls for hosted environments --- .../Models/DemoUiSecurityOptions.cs | 32 ++++++++ PayBridge.SDK.Example/Program.cs | 82 +++++++++++++++++++ .../DemoUiSecurityOptionsValidator.cs | 45 ++++++++++ .../appsettings.Development.json | 11 +++ PayBridge.SDK.Example/appsettings.json | 11 +++ .../DemoUiSecurityOptionsValidatorTests.cs | 77 +++++++++++++++++ 6 files changed, 258 insertions(+) create mode 100644 PayBridge.SDK.Example/Models/DemoUiSecurityOptions.cs create mode 100644 PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs create mode 100644 PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs diff --git a/PayBridge.SDK.Example/Models/DemoUiSecurityOptions.cs b/PayBridge.SDK.Example/Models/DemoUiSecurityOptions.cs new file mode 100644 index 0000000..f669c23 --- /dev/null +++ b/PayBridge.SDK.Example/Models/DemoUiSecurityOptions.cs @@ -0,0 +1,32 @@ +namespace PayBridge.SDK.Example.Models; + +public enum DemoRuntimeMode +{ + Mock = 0, + Sandbox = 1, + Live = 2 +} + +public sealed class DemoUiSecurityOptions +{ + public const string SectionName = "DemoUiSecurity"; + + // Hosted environments must require authenticated access to demo APIs. + public bool RequireAuthenticatedAccess { get; set; } = true; + + // Shared API key for non-local environments. Keep in secure stores only. + public string ApiKey { get; set; } = string.Empty; + + // Live mode is blocked by default and can only be enabled server-side. + public DemoRuntimeMode Mode { get; set; } = DemoRuntimeMode.Sandbox; + + public bool AllowLiveMode { get; set; } = false; + + // Basic anti-forgery guard for state-changing browser requests. + public bool RequireCsrfHeader { get; set; } = true; + public string CsrfHeaderName { get; set; } = "X-Console-CSRF"; + public string CsrfHeaderValue { get; set; } = string.Empty; + + // Global API throttling. + public int RequestsPerMinute { get; set; } = 60; +} diff --git a/PayBridge.SDK.Example/Program.cs b/PayBridge.SDK.Example/Program.cs index 16696ad..167711b 100644 --- a/PayBridge.SDK.Example/Program.cs +++ b/PayBridge.SDK.Example/Program.cs @@ -7,7 +7,10 @@ using PayBridge.SDK; using PayBridge.SDK.Enums; +using PayBridge.SDK.Example.Models; +using PayBridge.SDK.Example.Services; using Serilog; +using System.Threading.RateLimiting; // ── 1. LOGGING (optional — any ILogger provider works) ──────────────────── // @@ -36,6 +39,20 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddRateLimiter(options => +{ + options.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: "global", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 60, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new() @@ -54,6 +71,12 @@ c.IncludeXmlComments(xmlPath); }); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(DemoUiSecurityOptions.SectionName)) + .ValidateOnStart(); +builder.Services.AddSingleton, DemoUiSecurityOptionsValidator>(); + // ── 3a. DATABASE (optional) ──────────────────────────────────────────────── // // PayBridge can persist every transaction automatically. @@ -126,6 +149,8 @@ // ───────────────────────────────────────────────────────────────────────────── var app = builder.Build(); +var demoSecurity = app.Services.GetRequiredService>().Value; +var isHosted = !app.Environment.IsDevelopment(); // ── 5. MIDDLEWARE PIPELINE ───────────────────────────────────────────────── @@ -139,6 +164,63 @@ }); } +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["Content-Security-Policy"] = + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"; + await next(); +}); + +app.UseRateLimiter(); + +app.Use(async (context, next) => +{ + if (!context.Request.Path.StartsWithSegments("/api")) + { + await next(); + return; + } + + var isProviderCallback = + context.Request.Path.StartsWithSegments("/api/webhook") || + context.Request.Path.StartsWithSegments("/api/verify"); + + if (isHosted && demoSecurity.RequireAuthenticatedAccess && !isProviderCallback) + { + var suppliedApiKey = context.Request.Headers["X-Demo-Api-Key"].ToString(); + if (!string.Equals(suppliedApiKey, demoSecurity.ApiKey, StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { error = "unauthorized" }); + return; + } + } + + if (isHosted && demoSecurity.RequireCsrfHeader && !isProviderCallback && + HttpMethods.IsPost(context.Request.Method)) + { + var suppliedCsrf = context.Request.Headers[demoSecurity.CsrfHeaderName].ToString(); + if (!string.Equals(suppliedCsrf, demoSecurity.CsrfHeaderValue, StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { error = "invalid_csrf" }); + return; + } + } + + if (isHosted && demoSecurity.Mode == DemoRuntimeMode.Live && !demoSecurity.AllowLiveMode) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new { error = "live_mode_blocked" }); + return; + } + + await next(); +}); + app.UseHttpsRedirection(); app.MapControllers(); diff --git a/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs b/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs new file mode 100644 index 0000000..de164b4 --- /dev/null +++ b/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Options; +using PayBridge.SDK.Example.Models; + +namespace PayBridge.SDK.Example.Services; + +public sealed class DemoUiSecurityOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, DemoUiSecurityOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var failures = new List(); + if (options.RequestsPerMinute < 1) + { + failures.Add("DemoUiSecurity:RequestsPerMinute must be greater than zero."); + } + + if (options.Mode == DemoRuntimeMode.Live && !options.AllowLiveMode) + { + failures.Add("DemoUiSecurity:Mode cannot be Live unless DemoUiSecurity:AllowLiveMode is true."); + } + + if (options.RequireAuthenticatedAccess && string.IsNullOrWhiteSpace(options.ApiKey)) + { + failures.Add("DemoUiSecurity:ApiKey is required when DemoUiSecurity:RequireAuthenticatedAccess is true."); + } + + if (options.RequireCsrfHeader) + { + if (string.IsNullOrWhiteSpace(options.CsrfHeaderName)) + { + failures.Add("DemoUiSecurity:CsrfHeaderName is required when DemoUiSecurity:RequireCsrfHeader is true."); + } + + if (string.IsNullOrWhiteSpace(options.CsrfHeaderValue)) + { + failures.Add("DemoUiSecurity:CsrfHeaderValue is required when DemoUiSecurity:RequireCsrfHeader is true."); + } + } + + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } +} diff --git a/PayBridge.SDK.Example/appsettings.Development.json b/PayBridge.SDK.Example/appsettings.Development.json index c4abe29..923d4df 100644 --- a/PayBridge.SDK.Example/appsettings.Development.json +++ b/PayBridge.SDK.Example/appsettings.Development.json @@ -7,6 +7,17 @@ } }, + "DemoUiSecurity": { + "RequireAuthenticatedAccess": false, + "ApiKey": "YOUR_DEMO_API_KEY", + "Mode": "Sandbox", + "AllowLiveMode": false, + "RequireCsrfHeader": false, + "CsrfHeaderName": "X-Console-CSRF", + "CsrfHeaderValue": "YOUR_DEMO_CSRF_TOKEN", + "RequestsPerMinute": 120 + }, + "PaymentGatewayConfig": { "WebhookTimestampToleranceSeconds": 300, "EnabledGateways": [], diff --git a/PayBridge.SDK.Example/appsettings.json b/PayBridge.SDK.Example/appsettings.json index 37ba028..7b4d6dd 100644 --- a/PayBridge.SDK.Example/appsettings.json +++ b/PayBridge.SDK.Example/appsettings.json @@ -33,6 +33,17 @@ "PayBridgeDbContext": "YOUR_PAYBRIDGE_CONNECTION_STRING" }, + "DemoUiSecurity": { + "RequireAuthenticatedAccess": true, + "ApiKey": "YOUR_DEMO_API_KEY", + "Mode": "Sandbox", + "AllowLiveMode": false, + "RequireCsrfHeader": true, + "CsrfHeaderName": "X-Console-CSRF", + "CsrfHeaderValue": "YOUR_DEMO_CSRF_TOKEN", + "RequestsPerMinute": 60 + }, + "PaymentGatewayConfig": { "WebhookTimestampToleranceSeconds": 300, /* diff --git a/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs b/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs new file mode 100644 index 0000000..8db68c6 --- /dev/null +++ b/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using PayBridge.SDK.Example.Models; +using PayBridge.SDK.Example.Services; +using Xunit; + +namespace PayBridge.SDK.Test.Unit; + +[Trait("Category", "Unit")] +public class DemoUiSecurityOptionsValidatorTests +{ + [Fact] + public void Validate_rejects_live_mode_when_not_explicitly_enabled() + { + var validator = new DemoUiSecurityOptionsValidator(); + var options = NewOptions(); + options.Mode = DemoRuntimeMode.Live; + options.AllowLiveMode = false; + + var result = validator.Validate(null, options); + + result.Failed.Should().BeTrue(); + result.Failures.Should().Contain(item => item.Contains("Mode cannot be Live")); + } + + [Fact] + public void Validate_rejects_missing_api_key_when_hosted_access_is_required() + { + var validator = new DemoUiSecurityOptionsValidator(); + var options = NewOptions(); + options.RequireAuthenticatedAccess = true; + options.ApiKey = string.Empty; + + var result = validator.Validate(null, options); + + result.Failed.Should().BeTrue(); + result.Failures.Should().Contain(item => item.Contains("ApiKey is required")); + } + + [Fact] + public void Validate_rejects_missing_csrf_values_when_csrf_protection_is_enabled() + { + var validator = new DemoUiSecurityOptionsValidator(); + var options = NewOptions(); + options.RequireCsrfHeader = true; + options.CsrfHeaderName = string.Empty; + options.CsrfHeaderValue = string.Empty; + + var result = validator.Validate(null, options); + + result.Failed.Should().BeTrue(); + result.Failures.Should().Contain(item => item.Contains("CsrfHeaderName is required")); + result.Failures.Should().Contain(item => item.Contains("CsrfHeaderValue is required")); + } + + [Fact] + public void Validate_accepts_secure_sandbox_defaults() + { + var validator = new DemoUiSecurityOptionsValidator(); + var options = NewOptions(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeTrue(); + } + + private static DemoUiSecurityOptions NewOptions() => new() + { + RequireAuthenticatedAccess = true, + ApiKey = "demo-api-key", + Mode = DemoRuntimeMode.Sandbox, + AllowLiveMode = false, + RequireCsrfHeader = true, + CsrfHeaderName = "X-Console-CSRF", + CsrfHeaderValue = "csrf-token", + RequestsPerMinute = 60 + }; +} From c3ad2d3e4457651418f18059c872cd22dd309e6e Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 18 Jul 2026 16:49:22 +0100 Subject: [PATCH 2/2] Address PR security feedback for demo UI controls --- PayBridge.SDK.Example/Program.cs | 26 +++++++++++++---- .../DemoUiSecurityOptionsValidator.cs | 16 +++++++++++ .../appsettings.Development.json | 4 +-- PayBridge.SDK.Example/appsettings.json | 4 +-- .../DemoUiSecurityOptionsValidatorTests.cs | 28 +++++++++++++++++++ 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/PayBridge.SDK.Example/Program.cs b/PayBridge.SDK.Example/Program.cs index 167711b..dfc79cb 100644 --- a/PayBridge.SDK.Example/Program.cs +++ b/PayBridge.SDK.Example/Program.cs @@ -10,6 +10,8 @@ using PayBridge.SDK.Example.Models; using PayBridge.SDK.Example.Services; using Serilog; +using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Threading.RateLimiting; // ── 1. LOGGING (optional — any ILogger provider works) ──────────────────── @@ -39,6 +41,7 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); +var demoUiRequestsPerMinute = builder.Configuration.GetValue($"{DemoUiSecurityOptions.SectionName}:RequestsPerMinute", 60); builder.Services.AddRateLimiter(options => { options.GlobalLimiter = PartitionedRateLimiter.Create(_ => @@ -46,7 +49,7 @@ partitionKey: "global", factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 60, + PermitLimit = demoUiRequestsPerMinute, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true @@ -191,7 +194,7 @@ if (isHosted && demoSecurity.RequireAuthenticatedAccess && !isProviderCallback) { var suppliedApiKey = context.Request.Headers["X-Demo-Api-Key"].ToString(); - if (!string.Equals(suppliedApiKey, demoSecurity.ApiKey, StringComparison.Ordinal)) + if (!IsFixedTimeMatch(suppliedApiKey, demoSecurity.ApiKey)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new { error = "unauthorized" }); @@ -199,11 +202,16 @@ } } - if (isHosted && demoSecurity.RequireCsrfHeader && !isProviderCallback && - HttpMethods.IsPost(context.Request.Method)) + var isStateChangingMethod = + HttpMethods.IsPost(context.Request.Method) || + HttpMethods.IsPut(context.Request.Method) || + HttpMethods.IsPatch(context.Request.Method) || + HttpMethods.IsDelete(context.Request.Method); + + if (isHosted && demoSecurity.RequireCsrfHeader && !isProviderCallback && isStateChangingMethod) { var suppliedCsrf = context.Request.Headers[demoSecurity.CsrfHeaderName].ToString(); - if (!string.Equals(suppliedCsrf, demoSecurity.CsrfHeaderValue, StringComparison.Ordinal)) + if (!IsFixedTimeMatch(suppliedCsrf, demoSecurity.CsrfHeaderValue)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsJsonAsync(new { error = "invalid_csrf" }); @@ -225,3 +233,11 @@ app.MapControllers(); app.Run(); + +static bool IsFixedTimeMatch(string supplied, string expected) +{ + return supplied.Length == expected.Length && + CryptographicOperations.FixedTimeEquals( + MemoryMarshal.AsBytes(supplied.AsSpan()), + MemoryMarshal.AsBytes(expected.AsSpan())); +} diff --git a/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs b/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs index de164b4..0551068 100644 --- a/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs +++ b/PayBridge.SDK.Example/Services/DemoUiSecurityOptionsValidator.cs @@ -24,6 +24,10 @@ public ValidateOptionsResult Validate(string? name, DemoUiSecurityOptions option { failures.Add("DemoUiSecurity:ApiKey is required when DemoUiSecurity:RequireAuthenticatedAccess is true."); } + else if (options.RequireAuthenticatedAccess && IsPlaceholderSecret(options.ApiKey)) + { + failures.Add("DemoUiSecurity:ApiKey cannot use placeholder values in hosted mode."); + } if (options.RequireCsrfHeader) { @@ -36,10 +40,22 @@ public ValidateOptionsResult Validate(string? name, DemoUiSecurityOptions option { failures.Add("DemoUiSecurity:CsrfHeaderValue is required when DemoUiSecurity:RequireCsrfHeader is true."); } + else if (IsPlaceholderSecret(options.CsrfHeaderValue)) + { + failures.Add("DemoUiSecurity:CsrfHeaderValue cannot use placeholder values when CSRF protection is enabled."); + } } return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); } + + private static bool IsPlaceholderSecret(string value) + { + var trimmed = value.Trim(); + return trimmed.StartsWith("YOUR_", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("CHANGE_ME", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("REPLACE_ME", StringComparison.OrdinalIgnoreCase); + } } diff --git a/PayBridge.SDK.Example/appsettings.Development.json b/PayBridge.SDK.Example/appsettings.Development.json index 923d4df..95c52a9 100644 --- a/PayBridge.SDK.Example/appsettings.Development.json +++ b/PayBridge.SDK.Example/appsettings.Development.json @@ -9,12 +9,12 @@ "DemoUiSecurity": { "RequireAuthenticatedAccess": false, - "ApiKey": "YOUR_DEMO_API_KEY", + "ApiKey": "", "Mode": "Sandbox", "AllowLiveMode": false, "RequireCsrfHeader": false, "CsrfHeaderName": "X-Console-CSRF", - "CsrfHeaderValue": "YOUR_DEMO_CSRF_TOKEN", + "CsrfHeaderValue": "", "RequestsPerMinute": 120 }, diff --git a/PayBridge.SDK.Example/appsettings.json b/PayBridge.SDK.Example/appsettings.json index 7b4d6dd..26a0066 100644 --- a/PayBridge.SDK.Example/appsettings.json +++ b/PayBridge.SDK.Example/appsettings.json @@ -35,12 +35,12 @@ "DemoUiSecurity": { "RequireAuthenticatedAccess": true, - "ApiKey": "YOUR_DEMO_API_KEY", + "ApiKey": "", "Mode": "Sandbox", "AllowLiveMode": false, "RequireCsrfHeader": true, "CsrfHeaderName": "X-Console-CSRF", - "CsrfHeaderValue": "YOUR_DEMO_CSRF_TOKEN", + "CsrfHeaderValue": "", "RequestsPerMinute": 60 }, diff --git a/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs b/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs index 8db68c6..bd095f7 100644 --- a/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs +++ b/PayBridge.SDK.Test/Unit/DemoUiSecurityOptionsValidatorTests.cs @@ -36,6 +36,20 @@ public void Validate_rejects_missing_api_key_when_hosted_access_is_required() result.Failures.Should().Contain(item => item.Contains("ApiKey is required")); } + [Fact] + public void Validate_rejects_placeholder_api_key_when_hosted_access_is_required() + { + var validator = new DemoUiSecurityOptionsValidator(); + var options = NewOptions(); + options.RequireAuthenticatedAccess = true; + options.ApiKey = "YOUR_DEMO_API_KEY"; + + var result = validator.Validate(null, options); + + result.Failed.Should().BeTrue(); + result.Failures.Should().Contain(item => item.Contains("ApiKey cannot use placeholder")); + } + [Fact] public void Validate_rejects_missing_csrf_values_when_csrf_protection_is_enabled() { @@ -52,6 +66,20 @@ public void Validate_rejects_missing_csrf_values_when_csrf_protection_is_enabled result.Failures.Should().Contain(item => item.Contains("CsrfHeaderValue is required")); } + [Fact] + public void Validate_rejects_placeholder_csrf_value_when_csrf_protection_is_enabled() + { + var validator = new DemoUiSecurityOptionsValidator(); + var options = NewOptions(); + options.RequireCsrfHeader = true; + options.CsrfHeaderValue = "YOUR_DEMO_CSRF_TOKEN"; + + var result = validator.Validate(null, options); + + result.Failed.Should().BeTrue(); + result.Failures.Should().Contain(item => item.Contains("CsrfHeaderValue cannot use placeholder")); + } + [Fact] public void Validate_accepts_secure_sandbox_defaults() {