e3d5f0b1474ba392a87b2f65dc9701db3e88bfa2
max
  Sat Sep 26 21:54:08 2026 -0700
hgLogin: require POST and a per-page token to change the pending-signup address

diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c
index 431b4643002..119471da2fe 100644
--- src/hg/hgLogin/hgLogin.c
+++ src/hg/hgLogin/hgLogin.c
@@ -461,31 +461,36 @@
 /* A rejected address (changePendingEmail) leaves its reason here, and this page is where the
  * user sees it -- nothing else prints it on the way through. */
 if (isNotEmpty(errMsg))
     hPrintf("<p><span style='color:red;'>%s</span></p>", errMsg);
 hPrintf(
     "<p id=\"confirmationMsg\" class=\"confirmationTxt\">A confirmation email has been sent to you. \n"
     "Please click the confirmation link in the email to activate your account.</p>"
     "<p>You may have to look in your spam folder for an email from genome-www@soe.ucsc.edu, "
     "especially if you use Microsoft Outlook or Hotmail.</p>");
 if (offerNewAddress)
     {
     /* If that address is wrong the confirmation can never arrive, and this account has no other
      * way in, so offer to replace it here. */
     hPrintf("<p>If <b>%s</b> is not the right email address, enter the right one, and we will "
         "send the confirmation there instead.</p>", encAddress);
+    /* One token per render of this form, checked back by changePendingEmail: a link built
+     * without having seen this page cannot carry it. */
+    char *formToken = makeRandomKey(128+33);
+    cartSetString(cart, "oauth_pending_formToken", formToken);
     hPrintf("<form method=\"post\" action=\"%s\" name=\"fixEmailForm\">", hgLoginUrl);
+    hPrintf("<input type=\"hidden\" name=\"pendingFormToken\" value=\"%s\">", formToken);
     hPrintf("<div class=\"inputGroup\">"
         "<label for=\"fixEmailAddr\">Email address</label>"
         "<input type=\"text\" name=\"hgLogin_email\" value=\"\" size=\"30\" "
         "id=\"fixEmailAddr\"></div>");
     hPrintf("<div class=\"formControls\">"
         "<input type=\"submit\" name=\"hgLogin.do.changePendingEmail\" "
         "value=\"Use this address instead\" class=\"largeButton\"></div></form>");
     }
 hPrintf("\n<p><a href=\"%s\">Return</a></p>", returnURL);
 freeMem(encProvider);
 freeMem(encAddress);
 cartRemove(cart, "hgLogin_email");
 cartRemove(cart, "hgLogin_userName");
 cartRemove(cart, "hgLogin_actMailProvider");
 cartRemove(cart, "hgLogin_actMailTo");
@@ -3050,33 +3055,39 @@
 gbMembersFreeList(&matches);
 setPendingIdentity(id);
 completeAccountPage(conn);
 }
 
 void changePendingEmail(struct sqlConnection *conn)
 /* Put a different address on the unactivated account behind a still-valid pending identity, and
  * send the confirmation there.  Reached only from the confirmation page that resolveIdentity
  * shows such an account (see displayActMailSuccess), and the only way out for someone who
  * mistyped their address when the account was made: with no password and an address they cannot
  * read, every other route back in wants an activated account or a login cookie.
  * The pending signature is the authorization.  Only resolveIdentity mints one, only after a real
  * provider round trip, and only for this browser, so a request arriving here without one is
  * refused rather than trusted. */
 {
-char *provider = cartUsualString(cart, "oauth_pending_provider", "");
+// Clone this: clearPendingIdentity below frees the cart's copy, but we still use provider
+// after calling it, to label the confirmation page.
+char *provider = cloneString(cartUsualString(cart, "oauth_pending_provider", ""));
 char *subject = cartUsualString(cart, "oauth_pending_subject", "");
-if (isEmpty(provider) || isEmpty(subject) || !pendingIdentityValid())
+char *formToken = cartUsualString(cart, "oauth_pending_formToken", "");
+boolean postedRightToken = sameString(emptyForNull(cgiRequestMethod()), "POST") &&
+    isNotEmpty(formToken) && sameString(formToken, cgiUsualString("pendingFormToken", ""));
+cartRemove(cart, "oauth_pending_formToken");   // one-time use either way
+if (isEmpty(provider) || isEmpty(subject) || !pendingIdentityValid() || !postedRightToken)
     {
     clearPendingIdentity();
     freez(&errMsg);
     errMsg = cloneString("Your sign-in expired. Please sign in again.");
     displayLoginPage(conn);
     return;
     }
 struct oauthIdentity id;
 ZeroVar(&id);
 id.provider = provider;
 id.subject = subject;
 struct gbMembers *m = memberForIdentity(conn, &id);
 /* Only an account that is still waiting to be confirmed.  Once it is activated this page is
  * not reachable any more, and changing the address of a working account belongs in the
  * change-email flow, which confirms the new address before it takes effect. */
@@ -3100,30 +3111,32 @@
     freeMem(addrMatch);
     bad = (sqlQuickNum(conn, query) > 0);
     }
 if (!bad)
     {
     char query[512];
     sqlSafef(query, sizeof(query),
         "UPDATE gbMembers SET email='%s', emailToken='', emailTokenExpires='' WHERE idx=%u",
         email, m->idx);
     sqlUpdate(conn, query);
     setupNewAccount(conn, email, m->userName);   // new address, so a new token is right
     cartSetString(cart, "hgLogin_actMailTo", email);
     /* The page we are about to show is the same one the user just came from, so say that the
      * change went through.  Otherwise the swapped-in address is the only sign of it. */
     cartSetString(cart, "hgLogin_actMailChanged", "1");
+    // One change per pending identity: clear it so the form this came from cannot be reused.
+    clearPendingIdentity();
     }
 else
     {
     freez(&errMsg);
     errMsg = cloneString("Please enter an email address that is not already in use.");
     cartSetString(cart, "hgLogin_actMailTo", m->email);
     cartRemove(cart, "hgLogin_actMailChanged");
     }
 cartSetString(cart, "hgLogin_actMailProvider", oauthProviderLabel(provider));
 cartSetString(cart, "hgLogin_actMailUser", m->userName);
 cartRemove(cart, "hgLogin_email");
 gbMembersFree(&m);
 displayActMailSuccess();
 }
 
@@ -3374,30 +3387,31 @@
  * takes CGI variables verbatim (loadCgiOverHash in hg/lib/cart.c), so a copy arriving with
  * the request would stand in for the copy we stored.  Drop those before anything reads them;
  * a flow whose state is dropped fails closed and the user starts it again.  Note that
  * excludeVars would not do this job: it governs what is saved at the end of a request, not
  * what is read during it. */
 {
 static char *serverOwned[] = {
     "oauth_state", "oauth_provider",
     "oauth_pending_provider", "oauth_pending_subject", "oauth_pending_email",
     "oauth_pending_email_unverified",
     "oauth_pending_email_verified", "oauth_pending_name", "oauth_pending_time",
     "oauth_pending_sig",
     "emailLogin_email", "emailLogin_tokenMd5",
     "hgLogin_actMailProvider", "hgLogin_actMailTo", "hgLogin_actMailUser",
     "hgLogin_actMailUnverified", "hgLogin_actMailChanged",
+    "oauth_pending_formToken",
     };
 int i;
 for (i = 0;  i < ArraySize(serverOwned);  i++)
     if (cgiVarExists(serverOwned[i]))
         cartRemove(cart, serverOwned[i]);
 }
 
 void doMiddle(struct cart *theCart)
 /* Write the middle parts of the HTML page.
  * This routine sets up some globals and then
  * dispatches to the appropriate page-maker. */
 {
 struct sqlConnection *conn = hConnectCentral();
 
 // on mirrors, try to add the field 'recovEmail' to gbMembers. This may or may not work, depending on their config