Require current password for account email changes
What changed, and why it matters
This commit adds a security check so that when a user tries to change their email address on their account profile, they must enter their current password. Before this change, an attacker who had already hijacked a logged-in session could change the account email without proving they knew the password, making account takeover easier. The change is described by the project as low-impact.
No immediate action beyond normal deployment is needed. Review whether other sensitive account changes (e.g., password change, 2FA reset, API key generation) already require re-authentication, and consider applying the same pattern consistently if not already present.
Security signals we found
Account-takeover mitigation: email change now requires password re-authentication
New model property CurrentPassword with DataType.Password
Controller now calls CheckPasswordAsync before applying email change
Playwright regression test added for the new behavior
Changelog explicitly labels this as account security improvement
Evidence from the diff
The patch modifies UIManageController.Index(IndexViewModel) so that when the submitted Email differs from the stored email, the controller now verifies model.CurrentPassword using _userManager.CheckPasswordAsync before allowing the update. A new CurrentPassword property was added to IndexViewModel, the Index view now renders a password input with autocomplete=”current-password”, and a Playwright test confirms that changing the email without the password or with a wrong password is rejected while the correct password succeeds. The Changelog.md entry classifies this as low-impact account security, reported by llen.
Changed components
BTCPayServer/Controllers/UIManageController.csBTCPayServer/Models/ManageViewModels/IndexViewModel.csBTCPayServer/Views/UIManage/Index.cshtmlBTCPayServer.Tests/PlaywrightTests.csInspect captured patch +74 / −13
### .agents/skills/playwright-test-patterns/SKILL.md
@@ -33,7 +33,10 @@ Use these patterns when writing or refactoring Playwright tests in BTCPayServer.
- Playwright assertions automatically wait for the expected condition, making tests less verbose and less prone to flakiness.
- Prefer `await Expect(locator).ToHaveCountAsync(1)` over `Assert.Equal(1, await locator.CountAsync())`.
- Prefer `await Expect(locator).ToContainTextAsync(text)` over `Assert.Contains(text, await locator.TextContentAsync())`.
+- Prefer `await Expect(locator).ToHaveValueAsync(value)` over `Assert.Equal(value, await locator.InputValueAsync())`.
- Prefer `await Expect(page).ToHaveURLAsync(...)` over direct assertions on `page.Url`.
+- Do not call `WaitForLoadStateAsync` before a Playwright assertion; the `Expect` API waits for the expected state.
+- Add `using static Microsoft.Playwright.Assertions;` when using `Expect`.
## Selector Guidance
### AGENTS.md
@@ -6,8 +6,9 @@ Repository-specific agent guidance has moved to project skills:
- `.agents/skills/btcpayserver-changelog/SKILL.md`
- `.agents/skills/btcpayserver-pr-descriptions/SKILL.md`
- `.agents/skills/btcpayserver-configuration/SKILL.md`
+- `.agents/skills/playwright-test-patterns/SKILL.md`
-Load the relevant skill when creating migrations, updating/reviewing `Changelog.md`, writing/reviewing pull request descriptions, or adding/reviewing startup configuration options.
+Load the relevant skill when creating migrations, updating/reviewing `Changelog.md`, writing/reviewing pull request descriptions, adding/reviewing startup configuration options, or writing/refactoring Playwright tests.
## JSON Serialization
### BTCPayServer.Tests/PlaywrightTests.cs
@@ -989,6 +989,40 @@ public async Task NewUserLogin()
Assert.Contains("/login", s.Page.Url);
}
+ [Fact]
+ public async Task ChangingAccountEmailRequiresCurrentPassword()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ var oldEmail = await s.RegisterNewUser();
+ var newEmail = $"{RandomUtils.GetUInt256().ToString()[..20]}@example.com";
+ await s.SkipWizard();
+ await s.GoToUrl("/account");
+
+ await s.Page.FillAsync("#Email", newEmail);
+ await s.ClickPagePrimary();
+ await Expect(s.Page.Locator("[data-valmsg-for=CurrentPassword]")).ToContainTextAsync("The current password is not correct.");
+
+ await s.GoToUrl("/account");
+ await Expect(s.Page.Locator("#Email")).ToHaveValueAsync(oldEmail);
+ await s.Page.FillAsync("#Email", newEmail);
+ await s.Page.FillAsync("#CurrentPassword", "incorrect");
+ await s.ClickPagePrimary();
+ await Expect(s.Page.Locator("[data-valmsg-for=CurrentPassword]")).ToContainTextAsync("The current password is not correct.");
+
+ await s.GoToUrl("/account");
+ await Expect(s.Page.Locator("#Email")).ToHaveValueAsync(oldEmail);
+ await s.Page.FillAsync("#Email", newEmail);
+ await s.Page.FillAsync("#CurrentPassword", "123456");
+ await s.ClickPagePrimary();
+ await s.FindAlertMessage(partialText: "Your profile has been updated");
+ await Expect(s.Page.Locator("#Email")).ToHaveValueAsync(newEmail);
+
+ await s.Logout();
+ await s.LogIn(newEmail, "123456");
+ await s.Page.AssertNoError();
+ }
+
[Fact]
public async Task CanUseStoreTemplate()
{
### BTCPayServer/Controllers/UIManageController.cs
@@ -88,17 +88,7 @@ public async Task<IActionResult> Index()
var user = await _userManager.GetUserAsync(User);
if (user == null)
return NotFound();
- var blob = user.GetBlob() ?? new();
- var model = new IndexViewModel
- {
- Email = user.Email,
- Name = blob.Name,
- ImageUrl = string.IsNullOrEmpty(blob.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
- EmailConfirmed = user.EmailConfirmed,
- RequiresEmailConfirmation = user.RequiresEmailConfirmation,
- AllowGreenfieldBasicAuth = blob.AllowGreenfieldBasicAuth
- };
- return View(model);
+ return View(await GetIndexViewModel(user));
}
[HttpGet]
@@ -154,7 +144,15 @@ public async Task<IActionResult> Index(IndexViewModel model, [FromForm] bool Rem
bool needUpdate = false;
var email = user.Email;
- if (model.Email != email)
+ var setNewEmail = model.Email != email && ModelState.IsValid;
+ if (setNewEmail && (string.IsNullOrEmpty(model.CurrentPassword) ||
+ !await _userManager.CheckPasswordAsync(user, model.CurrentPassword)))
+ {
+ ModelState.AddModelError(nameof(model.CurrentPassword), StringLocalizer["The current password is not correct."].Value);
+ return View(await GetIndexViewModel(user, model));
+ }
+
+ if (setNewEmail)
{
if (!(await _userManager.FindByEmailAsync(model.Email) is null))
{
@@ -355,6 +353,21 @@ public async Task<IActionResult> DeleteUserPost()
#region Helpers
+ private async Task<IndexViewModel> GetIndexViewModel(ApplicationUser user, IndexViewModel model = null)
+ {
+ var blob = user.GetBlob() ?? new();
+ model ??= new IndexViewModel
+ {
+ Email = user.Email,
+ Name = blob.Name,
+ AllowGreenfieldBasicAuth = blob.AllowGreenfieldBasicAuth
+ };
+ model.ImageUrl = string.IsNullOrEmpty(blob.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl));
+ model.EmailConfirmed = user.EmailConfirmed;
+ model.RequiresEmailConfirmation = user.RequiresEmailConfirmation;
+ return model;
+ }
+
private void AddErrors(IdentityResult result)
{
foreach (var error in result.Errors)
### BTCPayServer/Models/ManageViewModels/IndexViewModel.cs
@@ -10,6 +10,9 @@ public class IndexViewModel
[MaxLength(50)]
[Display(Name = "Email")]
public string Email { get; set; }
+ [DataType(DataType.Password)]
+ [Display(Name = "Current password")]
+ public string CurrentPassword { get; set; }
public bool EmailConfirmed { get; set; }
public bool RequiresEmailConfirmation { get; set; }
[Display(Name = "Name")]
### BTCPayServer/Views/UIManage/Index.cshtml
@@ -39,6 +39,12 @@
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
+ <div class="form-group">
+ <label asp-for="CurrentPassword" class="form-label"></label>
+ <input asp-for="CurrentPassword" class="form-control" autocomplete="current-password" />
+ <div class="form-text" text-translate="true">Required only when changing your email address.</div>
+ <span asp-validation-for="CurrentPassword" class="text-danger"></span>
+ </div>
<div class="form-group">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" />
### Changelog.md
@@ -34,6 +34,7 @@
* **Stores**: Block unsafe support links to prevent script injection (#7537) @TChukwuleta
* **API Keys**: Keep new API keys out of authorization redirect URLs (#7542) @TChukwuleta
* **Maintenance**: Validate hostnames before changing a domain @NicolasDorier
+* **Account security**: Require the current password when changing an account email (low impact, reported by llen)
### Improvements
Why this scored 58/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.