Skip to content

Commit b5768a1

Browse files
committed
fix: Guard admin accounts on bypassed password reset and log bypass use
PasswordResetInitiateV2 had no privileged-account guard, so the turnstile bypass could send admin reset mail with no captcha and no rate limit. The lookup runs only on the bypass path and still returns the generic 200. BypassTokenMiddleware now logs accepted and unmatched tokens, never the token.
1 parent 8d6b508 commit b5768a1

4 files changed

Lines changed: 46 additions & 3 deletions

File tree

‎API/Controller/Account/PasswordResetInitiateV2.cs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using OpenShock.API.Models.Requests;
66
using OpenShock.API.Services.Turnstile;
77
using OpenShock.Common.Errors;
8+
using OpenShock.Common.Extensions;
9+
using OpenShock.Common.Models;
810
using OpenShock.Common.Problems;
911

1012
using OpenShock.Internal.Common.Problems;
@@ -37,6 +39,17 @@ public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordRese
3739
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
3840
if (turnstileError is not null) return turnstileError;
3941

42+
// Admin accounts must never be reached through a bypassed flow - the bypass exists for
43+
// automated tests, not as a way to send privileged reset mail without solving Turnstile.
44+
// The lookup runs only on the bypass path, so the normal path keeps its timing profile, and
45+
// the response stays the generic 200 so this does not become an admin-account oracle.
46+
if (HttpContext.IsBypassed(BypassTokenType.Turnstile)
47+
&& await _accountService.IsPrivilegedEmailAsync(body.Email, cancellationToken))
48+
{
49+
_logger.LogWarning("Refused a bypassed password reset for a privileged account");
50+
return Ok();
51+
}
52+
4053
await _accountService.CreatePasswordResetFlowAsync(body.Email);
4154

4255
return Ok();

‎API/Services/Account/AccountService.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ public Task<bool> IsEmailRegisteredAsync(string email, CancellationToken cancell
157157
return _db.Users.AnyAsync(u => u.Email == email, cancellationToken);
158158
}
159159

160+
public Task<bool> IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default)
161+
{
162+
email = email.ToLowerInvariant();
163+
return _db.Users.AnyAsync(u => u.Email == email && u.Roles.Contains(RoleType.Admin), cancellationToken);
164+
}
165+
160166
public async Task<OneOf<Success<User>, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync(
161167
string email,
162168
string username,

‎API/Services/Account/IAccountService.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ public interface IAccountService
3838
/// </summary>
3939
Task<bool> IsEmailRegisteredAsync(string email, CancellationToken cancellationToken = default);
4040

41+
/// <summary>
42+
/// True when the email belongs to an account holding a privileged role.
43+
/// Used to keep bypassed flows away from admin accounts, not for authorization decisions.
44+
/// </summary>
45+
Task<bool> IsPrivilegedEmailAsync(string email, CancellationToken cancellationToken = default);
46+
4147
/// <summary>
4248
///
4349
/// </summary>

‎Common/Middleware/BypassTokenMiddleware.cs‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
using System.Security.Cryptography;
1+
using System.Security.Cryptography;
22
using System.Text;
33
using OneOf.Types;
44
using OpenShock.Common.Extensions;
55
using OpenShock.Common.Models;
66
using OpenShock.Common.Services.Configuration;
7+
using Microsoft.Extensions.Logging;
78

89
namespace OpenShock.Common.Middleware;
910

@@ -27,7 +28,7 @@ public BypassTokenMiddleware(RequestDelegate next)
2728
_next = next;
2829
}
2930

30-
public async Task InvokeAsync(HttpContext context, IConfigurationService config)
31+
public async Task InvokeAsync(HttpContext context, IConfigurationService config, ILogger<BypassTokenMiddleware> logger)
3132
{
3233
if (!context.TryGetBypassTokenFromHeader(out var presented))
3334
{
@@ -40,7 +41,24 @@ public async Task InvokeAsync(HttpContext context, IConfigurationService config)
4041
if (await MatchesAsync(config, TurnstileConfigKey, presented)) matched |= BypassTokenType.Turnstile;
4142
if (await MatchesAsync(config, RateLimitConfigKey, presented)) matched |= BypassTokenType.RateLimit;
4243

43-
if (matched != BypassTokenType.None) context.SetBypassedTypes(matched);
44+
if (matched != BypassTokenType.None)
45+
{
46+
context.SetBypassedTypes(matched);
47+
48+
// A credential that switches off Turnstile and rate limiting should never be used without
49+
// leaving a trace. Logged at warning so it stands out in a production log, and the token
50+
// itself is never written - only which protections it disabled, and for what.
51+
logger.LogWarning(
52+
"Bypass token accepted for {Matched} on {Method} {Path} from {RemoteIp}",
53+
matched, context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress);
54+
}
55+
else
56+
{
57+
// A presented-but-unmatched token is either a stale secret or someone probing for one.
58+
logger.LogWarning(
59+
"Bypass token presented but matched nothing on {Method} {Path} from {RemoteIp}",
60+
context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress);
61+
}
4462

4563
await _next(context);
4664
}

0 commit comments

Comments
 (0)