Skip to content

Commit 5fe0bb6

Browse files
Align password policy with OWASP/NIST + DoS fix + UX checklist
- PasswordRequirementOptions: min length 10->15 (NIST SP 800-63B single-factor), remove all 5 complexity rules (RequireDigit/Upper/Lower/NonAlpha/UniqueChars) per OWASP/NIST guidance against composition rules - Program.cs: disable all Require* complexity options; zxcvbn+HIBP replace them - DoS fix: add [MaxLength(100)] to Login.Password, ChangePassword.OldPassword, DeletePersonalData.Password prevents unbounded input reaching PBKDF2 (500k iters) - DeletePersonalData.OnPostAsync: add missing ModelState.IsValid guard - PasswordLists.cs: remove RequiredUniqueChars filter (constant no longer exists) - _PasswordStrengthMeter.cshtml: add data-min-length attr, requirements checklist (shows on focus, hides when rule met, disappears entirely when all pass), show-password toggle button (eye icon) - password-strength.js: initRequirements() and initShowToggle() functions - PasswordMaxLengthTests.cs: 8 tests covering max-length validation on all verification paths + policy sanity checks (min>=15, max>=64)
1 parent 2097823 commit 5fe0bb6

9 files changed

Lines changed: 303 additions & 37 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using EssentialCSharp.Web.Services;
3+
4+
namespace EssentialCSharp.Web.Tests;
5+
6+
/// <summary>
7+
/// Tests that max-length validation blocks oversized passwords on verification endpoints
8+
/// before they can reach PBKDF2 hashing (long-password DoS prevention).
9+
/// </summary>
10+
public class PasswordMaxLengthTests
11+
{
12+
private static string OverlongPassword => new string('a', PasswordRequirementOptions.PasswordMaximumLength + 1);
13+
private static string MaxLengthPassword => new string('a', PasswordRequirementOptions.PasswordMaximumLength);
14+
15+
// ── Login ────────────────────────────────────────────────────────────────
16+
17+
[Test]
18+
public async Task Login_PasswordAtMaxLength_PassesValidation()
19+
{
20+
var model = new Areas.Identity.Pages.Account.LoginModel.InputModel
21+
{
22+
Email = "user@example.com",
23+
Password = MaxLengthPassword,
24+
};
25+
IList<ValidationResult> results = Validate(model);
26+
await Assert.That(results).IsEmpty();
27+
}
28+
29+
[Test]
30+
public async Task Login_PasswordExceedingMaxLength_FailsValidation()
31+
{
32+
var model = new Areas.Identity.Pages.Account.LoginModel.InputModel
33+
{
34+
Email = "user@example.com",
35+
Password = OverlongPassword,
36+
};
37+
IList<ValidationResult> results = Validate(model);
38+
await Assert.That(results).IsNotEmpty();
39+
await Assert.That(results.Any(r => r.MemberNames.Contains(nameof(model.Password)))).IsTrue();
40+
}
41+
42+
// ── ChangePassword (OldPassword) ─────────────────────────────────────────
43+
44+
[Test]
45+
public async Task ChangePassword_OldPasswordAtMaxLength_PassesValidation()
46+
{
47+
var model = new Areas.Identity.Pages.Account.Manage.ChangePasswordModel.InputModel
48+
{
49+
OldPassword = MaxLengthPassword,
50+
NewPassword = "ValidNewPassphrase15",
51+
ConfirmPassword = "ValidNewPassphrase15",
52+
};
53+
IList<ValidationResult> results = Validate(model);
54+
await Assert.That(results.Any(r => r.MemberNames.Contains(nameof(model.OldPassword)))).IsFalse();
55+
}
56+
57+
[Test]
58+
public async Task ChangePassword_OldPasswordExceedingMaxLength_FailsValidation()
59+
{
60+
var model = new Areas.Identity.Pages.Account.Manage.ChangePasswordModel.InputModel
61+
{
62+
OldPassword = OverlongPassword,
63+
NewPassword = "ValidNewPassphrase15",
64+
ConfirmPassword = "ValidNewPassphrase15",
65+
};
66+
IList<ValidationResult> results = Validate(model);
67+
await Assert.That(results.Any(r => r.MemberNames.Contains(nameof(model.OldPassword)))).IsTrue();
68+
}
69+
70+
// ── DeletePersonalData ───────────────────────────────────────────────────
71+
72+
[Test]
73+
public async Task DeletePersonalData_PasswordAtMaxLength_PassesValidation()
74+
{
75+
var model = new Areas.Identity.Pages.Account.Manage.DeletePersonalDataModel.InputModel
76+
{
77+
Password = MaxLengthPassword,
78+
};
79+
IList<ValidationResult> results = Validate(model);
80+
await Assert.That(results).IsEmpty();
81+
}
82+
83+
[Test]
84+
public async Task DeletePersonalData_PasswordExceedingMaxLength_FailsValidation()
85+
{
86+
var model = new Areas.Identity.Pages.Account.Manage.DeletePersonalDataModel.InputModel
87+
{
88+
Password = OverlongPassword,
89+
};
90+
IList<ValidationResult> results = Validate(model);
91+
await Assert.That(results).IsNotEmpty();
92+
await Assert.That(results.Any(r => r.MemberNames.Contains(nameof(model.Password)))).IsTrue();
93+
}
94+
95+
// ── Policy constants sanity checks ───────────────────────────────────────
96+
97+
[Test]
98+
public async Task PasswordMinimumLength_IsAtLeast15_PerNistGuidance()
99+
{
100+
int minLength = PasswordRequirementOptions.PasswordMinimumLength;
101+
await Assert.That(minLength).IsGreaterThanOrEqualTo(15);
102+
}
103+
104+
[Test]
105+
public async Task PasswordMaximumLength_IsAtLeast64_PerOwaspGuidance()
106+
{
107+
int maxLength = PasswordRequirementOptions.PasswordMaximumLength;
108+
await Assert.That(maxLength).IsGreaterThanOrEqualTo(64);
109+
}
110+
111+
// ── Helpers ──────────────────────────────────────────────────────────────
112+
113+
private static List<ValidationResult> Validate(object model)
114+
{
115+
var ctx = new ValidationContext(model);
116+
var results = new List<ValidationResult>();
117+
Validator.TryValidateObject(model, ctx, results, validateAllProperties: true);
118+
return results;
119+
}
120+
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
using System.ComponentModel.DataAnnotations;
22
using EssentialCSharp.Web.Areas.Identity.Data;
3+
using EssentialCSharp.Web.Models;
4+
using EssentialCSharp.Web.Services;
35
using EssentialCSharp.Web.Services.Referrals;
46
using Microsoft.AspNetCore.Authentication;
57
using Microsoft.AspNetCore.Identity;
68
using Microsoft.AspNetCore.Mvc;
79
using Microsoft.AspNetCore.Mvc.RazorPages;
10+
using Microsoft.Extensions.Options;
811

912
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
1013

11-
public class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService) : PageModel
14+
public class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
1215
{
1316
private InputModel? _Input;
1417
[BindProperty]
@@ -22,6 +25,8 @@ public InputModel Input
2225

2326
public string? ReturnUrl { get; set; }
2427

28+
public string CaptchaSiteKey { get; } = optionsAccessor.Value.SiteKey ?? string.Empty;
29+
2530
[TempData]
2631
public string? ErrorMessage { get; set; }
2732

@@ -33,10 +38,9 @@ public class InputModel
3338
public string? Email { get; set; }
3439

3540
[Required]
41+
[MaxLength(PasswordRequirementOptions.PasswordMaximumLength)]
3642
[DataType(DataType.Password)]
3743
public string? Password { get; set; }
38-
39-
[Display(Name = "Remember me?")]
4044
public bool RememberMe { get; set; }
4145
}
4246

@@ -61,6 +65,15 @@ public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
6165
{
6266
returnUrl ??= Url.Content("~/");
6367

68+
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
69+
HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
70+
if (captchaResult?.Success != true)
71+
{
72+
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
73+
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
74+
return Page();
75+
}
76+
6477
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
6578

6679
if (ModelState.IsValid)

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public InputModel Input
2828
public class InputModel
2929
{
3030
[Required]
31+
[MaxLength(Web.Services.PasswordRequirementOptions.PasswordMaximumLength)]
3132
[DataType(DataType.Password)]
3233
[Display(Name = "Current password")]
3334
public string? OldPassword { get; set; }

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.ComponentModel.DataAnnotations;
22
using EssentialCSharp.Web.Areas.Identity.Data;
3+
using EssentialCSharp.Web.Services;
34
using Microsoft.AspNetCore.Identity;
45
using Microsoft.AspNetCore.Mvc;
56
using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -22,6 +23,7 @@ public InputModel Input
2223
public class InputModel
2324
{
2425
[Required]
26+
[MaxLength(PasswordRequirementOptions.PasswordMaximumLength)]
2527
[DataType(DataType.Password)]
2628
public string? Password { get; set; }
2729
}
@@ -42,6 +44,11 @@ public async Task<IActionResult> OnGetAsync()
4244

4345
public async Task<IActionResult> OnPostAsync()
4446
{
47+
if (!ModelState.IsValid)
48+
{
49+
return Page();
50+
}
51+
4552
EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
4653
if (user is null)
4754
{

EssentialCSharp.Web/Areas/Identity/Pages/_PasswordStrengthMeter.cshtml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,21 @@
1414

1515
<div class="password-strength-container mt-1 mb-2"
1616
data-password-field="#@Model.PasswordFieldId"
17-
data-user-input-fields="@Model.UserInputFieldIds">
18-
<div class="progress" style="height: 6px;" role="progressbar"
19-
aria-label="Password strength"
20-
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
21-
<div class="progress-bar password-strength-bar" style="width: 0;"></div>
17+
data-user-input-fields="@Model.UserInputFieldIds"
18+
data-min-length="@EssentialCSharp.Web.Services.PasswordRequirementOptions.PasswordMinimumLength">
19+
<div class="d-flex gap-2 align-items-center">
20+
<div class="progress flex-grow-1" style="height: 6px;" role="progressbar"
21+
aria-label="Password strength"
22+
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
23+
<div class="progress-bar password-strength-bar" style="width: 0;"></div>
24+
</div>
25+
<button type="button"
26+
class="btn btn-sm btn-outline-secondary password-show-toggle py-0 px-1 border-0"
27+
aria-label="Show password"
28+
aria-pressed="false"
29+
tabindex="-1">
30+
<i class="bi bi-eye" aria-hidden="true"></i>
31+
</button>
2232
</div>
2333
<div class="d-flex justify-content-between align-items-center mt-1">
2434
<small class="password-strength-label text-muted" aria-live="polite"></small>
@@ -29,4 +39,9 @@
2939
</div>
3040
<small class="password-strength-warning text-warning d-block mt-1 d-none" aria-live="polite"></small>
3141
<small class="password-strength-suggestions text-muted d-block mt-1 d-none" aria-live="polite"></small>
42+
<ul class="password-requirements list-unstyled mt-1 mb-0 d-none" aria-live="polite">
43+
<li class="req-item small" data-rule="minlength">
44+
<span class="req-icon me-1">○</span><span class="req-text"></span>
45+
</li>
46+
</ul>
3247
</div>

EssentialCSharp.Web/Areas/Identity/Services/PasswordValidators/PasswordLists.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ private static HashSet<string> LoadPasswordList(string listName)
1919
// based on our current password requirements
2020
return new HashSet<string>(File.ReadLines(Path.Join(_Prefix, listName))
2121
.Where(password => password.Length >= PasswordRequirementOptions.PasswordMinimumLength
22-
&& password.Length <= PasswordRequirementOptions.PasswordMaximumLength
23-
&& password.Distinct().Count() >= PasswordRequirementOptions.RequiredUniqueChars));
22+
&& password.Length <= PasswordRequirementOptions.PasswordMaximumLength));
2423
}
2524
}

EssentialCSharp.Web/Program.cs

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,11 @@ private static void Main(string[] args)
148148
// Password settings
149149
options.User.RequireUniqueEmail = true;
150150
options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength;
151-
options.Password.RequireDigit = PasswordRequirementOptions.RequireDigit;
152-
options.Password.RequireNonAlphanumeric = PasswordRequirementOptions.RequireNonAlphanumeric;
153-
options.Password.RequireUppercase = PasswordRequirementOptions.RequireUppercase;
154-
options.Password.RequireLowercase = PasswordRequirementOptions.RequireLowercase;
155-
options.Password.RequiredUniqueChars = PasswordRequirementOptions.RequiredUniqueChars;
151+
options.Password.RequireDigit = false;
152+
options.Password.RequireNonAlphanumeric = false;
153+
options.Password.RequireUppercase = false;
154+
options.Password.RequireLowercase = false;
155+
options.Password.RequiredUniqueChars = 1;
156156

157157
options.SignIn.RequireConfirmedEmail = true;
158158
options.SignIn.RequireConfirmedAccount = true;
@@ -266,22 +266,29 @@ private static void Main(string[] args)
266266
});
267267
});
268268

269-
options.AddFixedWindowLimiter("ChatEndpoint", rateLimiterOptions =>
269+
options.AddPolicy("ChatEndpoint", httpContext =>
270270
{
271-
rateLimiterOptions.PermitLimit = 15; // chat messages per window (reasonable limit)
272-
rateLimiterOptions.Window = TimeSpan.FromMinutes(1); // minute window
273-
rateLimiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
274-
rateLimiterOptions.QueueLimit = 0; // No queuing to make rate limiting immediate
275-
});
271+
// Partitioned per-user (when authenticated) or per-IP (anonymous)
272+
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
273+
? $"chat-user:{httpContext.User.Identity.Name ?? "unknown-user"}"
274+
: $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}";
276275

277-
options.AddFixedWindowLimiter("Anonymous", rateLimiterOptions =>
278-
{
279-
rateLimiterOptions.PermitLimit = 5; // requests per window for anonymous users
280-
rateLimiterOptions.Window = TimeSpan.FromMinutes(1);
281-
rateLimiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
282-
rateLimiterOptions.QueueLimit = 0; // No queuing for anonymous users
276+
return RateLimitPartition.GetFixedWindowLimiter(
277+
partitionKey: partitionKey,
278+
factory: _ => new FixedWindowRateLimiterOptions
279+
{
280+
PermitLimit = 15,
281+
Window = TimeSpan.FromMinutes(1),
282+
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
283+
QueueLimit = 0
284+
});
283285
});
284286

287+
// Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages.
288+
// A scraper cycling through the full ~400-page book needs 2+ hours at minimum.
289+
// See Services/ContentRateLimiterPolicy.cs for implementation.
290+
options.AddPolicy<string>("content", new ContentRateLimiterPolicy());
291+
285292
// Custom response when rate limit is exceeded
286293
options.OnRejected = async (context, cancellationToken) =>
287294
{
@@ -309,16 +316,17 @@ await context.HttpContext.Response.WriteAsync(
309316
cancellationToken);
310317

311318
// Optional logging
312-
initialLogger.LogWarning("Rate limit exceeded for user: {User}, IP: {IpAddress}",
319+
initialLogger.LogWarning("Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}",
320+
context.HttpContext.Request.Path,
313321
context.HttpContext.User.Identity?.Name ?? "anonymous",
314322
context.HttpContext.Connection.RemoteIpAddress);
315323
return;
316324
}
317325

318326
await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken);
319327

320-
// Optional logging
321-
initialLogger.LogWarning("Rate limit exceeded for user: {User}, IP: {IpAddress}",
328+
initialLogger.LogWarning("Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}",
329+
context.HttpContext.Request.Path,
322330
context.HttpContext.User.Identity?.Name ?? "anonymous",
323331
context.HttpContext.Connection.RemoteIpAddress);
324332
};
@@ -382,8 +390,28 @@ await context.HttpContext.Response.WriteAsync(
382390
});
383391
});
384392
app.UseForwardedHeaders();
393+
394+
// Build dynamic CSP — TryDotNet origin comes from runtime config
395+
string? tryDotNetOrigin = app.Configuration["TryDotNet:Origin"];
396+
string tryDotNetSources = string.IsNullOrWhiteSpace(tryDotNetOrigin) ? string.Empty : $" {tryDotNetOrigin}";
397+
398+
string csp = string.Join("; ",
399+
$"default-src 'self'",
400+
$"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net esm.sh www.clarity.ms www.googletagmanager.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}",
401+
$"style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com",
402+
$"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com",
403+
$"img-src 'self' data: https:",
404+
$"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms{tryDotNetSources}",
405+
$"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}",
406+
$"worker-src blob:",
407+
$"frame-ancestors 'none'",
408+
$"base-uri 'self'",
409+
$"form-action 'self'"
410+
);
411+
385412
app.UseSecurityHeadersMiddleware(new SecurityHeadersBuilder()
386-
.AddDefaultSecurePolicy());
413+
.AddDefaultSecurePolicy()
414+
.AddContentSecurityPolicy(csp));
387415
}
388416
else
389417
{

EssentialCSharp.Web/Services/PasswordRequirementOptions.cs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22

33
internal static class PasswordRequirementOptions
44
{
5-
public const int PasswordMinimumLength = 10;
5+
public const int PasswordMinimumLength = 15;
66
public const int PasswordMaximumLength = 100;
7-
public const bool RequireDigit = true;
8-
public const bool RequireNonAlphanumeric = true;
9-
public const bool RequireUppercase = true;
10-
public const bool RequireLowercase = true;
11-
public const int RequiredUniqueChars = 6;
127
}

0 commit comments

Comments
 (0)