Throttle API calls with Basic auth (#7177)
What changed, and why it matters
This change adds speed bumps (rate limiting) to API logins that use username-and-password Basic authentication. It is a hardening fix: before, attackers could make unlimited rapid login attempts through the API; now those attempts are throttled after the account is more than five minutes old. The patch also improves error messages so the server can tell callers exactly why authentication failed, including when they are rate-limited.
Treat as a security hardening patch. Ensure production deployments receive this change, confirm the rate-limit zone (ZoneLimits.Login) is configured with appropriate thresholds, and monitor for any legitimate integrations that rely on repeated Basic-auth API calls from fixed IPs.
Security signals we found
Rate limiting added to Basic authentication on Greenfield API
Authentication failure reasons propagated to 401 JSON responses
Test asserts repeated Basic-auth requests for an old account are throttled with 'Rate limited' message
New accounts exempted from throttling for first 5 minutes to allow API-key onboarding
Evidence from the diff
The commit modifies BasicAuthenticationHandler to call IRateLimitService.Throttle(ZoneLimits.Login, remoteIp) for Basic-auth Greenfield API requests when the user account is older than five minutes. It also refactors both API-key and Basic-auth handlers to store a failure reason in HttpContext.Items under APIKeysAuthenticationHandler.AuthFailureReason, which GreenfieldMiddleware now surfaces in the 401 JSON response. Tests were added to verify that new accounts are not throttled but accounts older than one day become rate-limited after repeated Basic-auth calls.
Changed components
BTCPayServer/Security/GreenField/BasicAuthenticationHandler.csBTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.csBTCPayServer/Hosting/GreenfieldMiddleware.csBTCPayServer.Tests/GreenfieldAPITests.csInspect captured patch +78 / −20
diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs
index 6b345a7..b71e592 100644
--- a/BTCPayServer.Tests/ApiKeysTests.cs
+++ b/BTCPayServer.Tests/ApiKeysTests.cs
@@ -48,13 +48,13 @@ namespace BTCPayServer.Tests
async Task AssertNoPermission(string permission)
{
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
var txt = await s.Page.ContentAsync();
Assert.DoesNotContain(permission, txt);
}
async Task AssertPermission(string permission)
{
- var txt = await s.Page.ContentAsync();
- Assert.Contains(permission, txt);
+ await s.Page.Locator($".text-muted:has-text('{permission}')").WaitForAsync();
}
//not an admin, so this permission should not show
diff --git a/BTCPayServer.Tests/AssertEx.cs b/BTCPayServer.Tests/AssertEx.cs
index 7fbf4f8..a6cbe65 100644
--- a/BTCPayServer.Tests/AssertEx.cs
+++ b/BTCPayServer.Tests/AssertEx.cs
@@ -32,11 +32,12 @@ public class AssertEx
var ex = await Assert.ThrowsAsync<GreenfieldAPIException>(act);
Assert.Equal(code, ex.HttpCode);
}
- public static async Task AssertApiError(int httpStatus, string errorCode, Func<Task> act)
+ public static async Task<GreenfieldAPIException> AssertApiError(int httpStatus, string errorCode, Func<Task> act)
{
var ex = await Assert.ThrowsAsync<GreenfieldAPIException>(act);
Assert.Equal(httpStatus, ex.HttpCode);
Assert.Equal(errorCode, ex.APIError.Code);
+ return ex;
}
public static async Task<GreenfieldAPIException> AssertApiError(string expectedError, Func<Task> act)
diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs
index ca9ef72..25b3004 100644
--- a/BTCPayServer.Tests/BTCPayServerTester.cs
+++ b/BTCPayServer.Tests/BTCPayServerTester.cs
@@ -28,6 +28,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBXplorer;
@@ -184,6 +185,7 @@ namespace BTCPayServer.Tests
{
options.ValidateScopes = true;
})
+ .UseEnvironment(HostEnvironment)
.UseConfiguration(conf)
.UseContentRoot(FindBTCPayServerDirectory())
.UseWebRoot(Path.Combine(FindBTCPayServerDirectory(), "wwwroot"))
@@ -344,6 +346,7 @@ namespace BTCPayServer.Tests
public string SSHKeyFile { get; internal set; }
public string SSHConnection { get; set; }
public bool NoCSP { get; set; }
+ public string HostEnvironment { get; set; } = Environments.Development;
public T GetController<T>(string userId = null, string storeId = null, bool isAdmin = false) where T : Controller
{
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index 38391b2..ec81284 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
+using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
@@ -22,8 +23,10 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
+using Dapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json;
@@ -1358,9 +1361,10 @@ namespace BTCPayServer.Tests
{
using var tester = CreateServerTester(newDb: true);
tester.PayTester.DisableRegistration = true;
+ tester.PayTester.HostEnvironment = Environments.Production;
await tester.StartAsync();
var user = tester.NewAccount();
- user.GrantAccess();
+ await user.GrantAccessAsync();
await user.MakeAdmin();
var clientProfile = await user.CreateClient(Policies.CanModifyProfile);
var clientServer = await user.CreateClient(Policies.CanCreateUser, Policies.CanViewProfile);
@@ -1410,6 +1414,29 @@ namespace BTCPayServer.Tests
await AssertValidationError(new[] { "Email" }, async () =>
await clientServer.CreateUser(
new CreateApplicationUserRequest() { Password = Guid.NewGuid().ToString() }));
+
+ // No rate limit for new accounts
+ for (var i = 0; i < 10; i++)
+ {
+ await clientBasic.GetCurrentUser();
+ }
+
+ var facto = tester.PayTester.GetService<ApplicationDbContextFactory>();
+ await using var ctx = facto.CreateContext();
+ await ctx.Database.GetDbConnection().ExecuteAsync("""
+ UPDATE "AspNetUsers"
+ SET "Created"= NOW() - interval '1 day'
+ WHERE "Id"=@id
+ """, new{ id = user.UserId });
+
+ var err = await AssertEx.AssertApiError(401, "unauthenticated", async () =>
+ {
+ for (var i = 0; i < 10; i++)
+ {
+ await clientBasic.GetCurrentUser();
+ }
+ });
+ Assert.Contains("Rate limited", err.Message);
}
[Fact(Timeout = TestTimeout)]
diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs
index a443b48..2b7320d 100644
--- a/BTCPayServer.Tests/POSTests.cs
+++ b/BTCPayServer.Tests/POSTests.cs
@@ -1063,6 +1063,7 @@ goodies:
await s.ClickPagePrimary();
await s.FindAlertMessage();
+ await s.Page.Locator("#CodeTabButton").WaitForAsync();
await s.Page.Locator("#CodeTabButton").ScrollIntoViewIfNeededAsync();
await s.Page.ClickAsync("#CodeTabButton");
template = await s.Page.Locator("#TemplateConfig").InputValueAsync();
@@ -1104,6 +1105,7 @@ goodies:
// Let's set change the root app
await s.GoToHome();
await s.GoToServer(ServerNavPages.Policies);
+ await s.Page.Locator("#RootAppId").WaitForAsync();
await s.Page.Locator("#RootAppId").ScrollIntoViewIfNeededAsync();
var options = await s.Page.Locator("#RootAppId option").AllTextContentsAsync();
@@ -1140,10 +1142,8 @@ goodies:
// Let's check with domain mapping as well.
await s.GoToUrl(prevUrl);
await s.GoToServer(ServerNavPages.Policies);
- await s.Page.Locator("#RootAppId").ScrollIntoViewIfNeededAsync();
await s.Page.Locator("#RootAppId").SelectOptionAsync("");
await s.ClickPagePrimary();
- await s.Page.Locator("#RootAppId").ScrollIntoViewIfNeededAsync();
await s.Page.ClickAsync("#AddDomainButton");
await s.Page.Locator("#DomainToAppMapping_0__Domain").FillAsync(new Uri(s.Page.Url, UriKind.Absolute).DnsSafeHost);
diff --git a/BTCPayServer/Hosting/GreenfieldMiddleware.cs b/BTCPayServer/Hosting/GreenfieldMiddleware.cs
index fde8dd7..08257ef 100644
--- a/BTCPayServer/Hosting/GreenfieldMiddleware.cs
+++ b/BTCPayServer/Hosting/GreenfieldMiddleware.cs
@@ -43,7 +43,9 @@ namespace BTCPayServer.Hosting
}
if (httpContext.Response.StatusCode == 401)
{
- var outputObj = new GreenfieldAPIError("unauthenticated", "Authentication is required for accessing this endpoint");
+ httpContext.Items.TryGetValue(APIKeysAuthenticationHandler.AuthFailureReason, out var reason);
+ var reasonStr = reason as string ?? "Authentication is required for accessing this endpoint";
+ var outputObj = new GreenfieldAPIError("unauthenticated", reasonStr);
await WriteError(httpContext, outputObj);
}
}
diff --git a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
index 0e8a9ce..ddb9025 100644
--- a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
+++ b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
@@ -26,6 +26,7 @@ namespace BTCPayServer.Security.Greenfield
UserManager<ApplicationUser> userManager)
: AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
{
+ public const string AuthFailureReason = "Greenfield-" + nameof(AuthFailureReason);
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
// This one deserve some explanation...
@@ -35,7 +36,7 @@ namespace BTCPayServer.Security.Greenfield
// Now, if we aren't logged nor authenticated via greenfield, the AuthenticationHandlers get challenged.
// The last handler to be challenged is the CookieAuthenticationHandler, which instruct to handle the challenge as a redirection to
// the login page.
- // But this isn't what we want when we call the API programmatically, instead we want an error 401 with a json error message.
+ // But this isn't what we want when we call the API programmatically, instead we want an error 401 with a JSON error message.
// This hack modify a request's header to trick the CookieAuthenticationHandler to not do a redirection.
if (!Request.Headers.Accept.Any(s => s != null && s.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)))
Request.Headers.XRequestedWith = new Microsoft.Extensions.Primitives.StringValues("XMLHttpRequest");
@@ -48,11 +49,11 @@ namespace BTCPayServer.Security.Greenfield
return AuthenticateResult.NoResult();
var key = await apiKeyRepository.GetKey(apiKey, true);
- var loggingContext = new UserService.CanLoginContext(key?.User, baseUrl: Request.GetRequestBaseUrl());
- if (!await userService.CanLogin(loggingContext) || key is null)
- {
- return AuthenticateResult.Fail($"ApiKey authentication failed: {loggingContext.Failures[0].Text.Value}");
- }
+ if (key is null)
+ return Fail($"ApiKey authentication failed: Unknown API Key");
+ var loggingContext = new UserService.CanLoginContext(key.User, baseUrl: Request.GetRequestBaseUrl());
+ if (!await userService.CanLogin(loggingContext))
+ return Fail($"ApiKey authentication failed: {loggingContext.Failures[0].Text.Value}");
var claims = new List<Claim> { new (identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) };
claims.AddRange((await userManager.GetRolesAsync(key.User)).Select(s => new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
@@ -62,5 +63,11 @@ namespace BTCPayServer.Security.Greenfield
new ClaimsPrincipal(new ClaimsIdentity(claims, GreenfieldConstants.AuthenticationType)),
GreenfieldConstants.AuthenticationType));
}
+
+ AuthenticateResult Fail(string reason)
+ {
+ Context.Items.TryAdd(AuthFailureReason, reason);
+ return AuthenticateResult.Fail(reason);
+ }
}
}
diff --git a/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs
index bd2e92b..19d442d 100644
--- a/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs
+++ b/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs
@@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using NicolasDorier.RateLimits;
namespace BTCPayServer.Security.Greenfield
{
@@ -23,7 +24,8 @@ namespace BTCPayServer.Security.Greenfield
ILoggerFactory logger,
UrlEncoder encoder,
SignInManager<ApplicationUser> signInManager,
- UserService UserService,
+ UserService userService,
+ IRateLimitService rateLimitService,
UserManager<ApplicationUser> userManager)
: AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
{
@@ -38,7 +40,7 @@ namespace BTCPayServer.Security.Greenfield
try
{
var encodedUsernamePassword =
- authHeader.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[1]?.Trim();
+ authHeader.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[1].Trim();
var decodedUsernamePassword =
Encoding.UTF8.GetString(Convert.FromBase64String(encodedUsernamePassword)).Split(':');
username = decodedUsernamePassword[0];
@@ -46,7 +48,7 @@ namespace BTCPayServer.Security.Greenfield
}
catch (Exception)
{
- return AuthenticateResult.Fail(
+ return Fail(
"Basic authentication header was not in a correct format. (username:password encoded in base64)");
}
@@ -54,18 +56,28 @@ namespace BTCPayServer.Security.Greenfield
.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser =>
applicationUser.NormalizedUserName == userManager.NormalizeName(username));
+
+ // We disable throttling for new accounts to give time to create API keys via greenfield API.
+ if (user.Created is not {} created ||
+ (DateTimeOffset.UtcNow - created) > TimeSpan.FromMinutes(5))
+ {
+ if (Context.Connection.RemoteIpAddress?.ToString() is string ip)
+ if (!await rateLimitService.Throttle(ZoneLimits.Login, ip))
+ return Fail($"Basic authentication failed: Rate limited. Please use authentication with API Keys to avoid throttling.");
+ }
+
var loggingContext = new UserService.CanLoginContext(user, baseUrl: Request.GetRequestBaseUrl());
- if (!await UserService.CanLogin(loggingContext))
+ if (!await userService.CanLogin(loggingContext))
{
- return AuthenticateResult.Fail($"Basic authentication failed: {loggingContext.Failures[0].Text.Value}");
+ return Fail($"Basic authentication failed: {loggingContext.Failures[0].Text.Value}");
}
if (user.Fido2Credentials.Any())
{
- return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled.");
+ return Fail("Cannot use Basic authentication when multi-factor is enabled.");
}
var result = await signInManager.CheckPasswordSignInAsync(user, password, true);
if (!result.Succeeded)
- return AuthenticateResult.Fail(result.ToString());
+ return Fail(result.ToString());
var claims = new List<Claim>()
{
new Claim(identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, user.Id),
@@ -78,5 +90,11 @@ namespace BTCPayServer.Security.Greenfield
new ClaimsPrincipal(new ClaimsIdentity(claims, GreenfieldConstants.AuthenticationType)),
GreenfieldConstants.AuthenticationType));
}
+
+ AuthenticateResult Fail(string reason)
+ {
+ Context.Items.TryAdd(APIKeysAuthenticationHandler.AuthFailureReason, reason);
+ return AuthenticateResult.Fail(reason);
+ }
}
}
Why this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.