1d079d218c274f09b61ec390619782e0c505d157
max
  Tue Sep 1 06:44:56 2026 -0700
hgLogin: confirm the recovery email address by mail, refs #38197

The optional recovery address given at signup is now mailed a signed
confirmation link, and counts for sign-in and password recovery only once
that link has been opened. The link is signed with login.cookieSalt, expires
after a week and works exactly once.

Adds gbMembers.recovEmailVerified through the usual auto-upgrade. Existing
rows default to 'Y', so recovery addresses set up before this keep working
and nobody has to re-confirm one. An install that sends no mail, or that has
no login.cookieSalt to sign a link with, stores the address as it does today.

diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c
index 85904827b20..fcec1851abd 100644
--- src/hg/hgLogin/hgLogin.c
+++ src/hg/hgLogin/hgLogin.c
@@ -36,44 +36,47 @@
 #include "hCommon.h"
 #include "botDelay.h"
 #include "errCatch.h"
 
 #define EMAILSEP ";"
 
 /* ---- Global variables. ---- */
 char msg[4096] = "";
 char *incorrectUsernameOrPassword="The username or password you entered is incorrect.";
 char *incorrectUsername="The username you entered is incorrect.";
 /* The excludeVars are not saved to the cart. */
 char *excludeVars[] = { "submit", "Submit", "debug", "fixMembers", "update",
      "hgLogin_password", "hgLogin_password2", "hgLogin_newPassword1",
      "hgLogin_newPassword2", "hgLogin_newEmail1", "hgLogin_newEmail2",
      "hgLogin_curPassword", "code", "state", "provider", "user", "token",
-     "newEmail", "exp", "sig", NULL };
+     "newEmail", "recovEmail", "exp", "sig", NULL };
 struct cart *cart;	/* This holds cgi and other variables between clicks. */
 char *database;		/* Name of genome database - hg15, mm3, or the like. */
 struct hash *oldCart;	/* Old cart hash. */
 char *errMsg = NULL;    /* Error message to show user when form data rejected */
 char brwName[64];
 char brwAddr[256];
 char signature[256];
 char returnAddr[256];
 char *hgLoginUrl = NULL; /* full absolute URL to hgLogin as seen from browser,
     e.g. http://genome.ucsc.edu/cgi-bin/hgLogin. Can be a relative URL /cgi-bin/hgLogin if
     hg.conf login.relativeLink is on. */
 boolean pwdEyeIconEnabled = TRUE; /* show/hide eye icon on password fields;
     set from hg.conf login.pwdEyeIcon in doMiddle() */
+boolean recovEmailVerifyOk = FALSE; /* TRUE when gbMembers has the recovEmailVerified column, so
+    a confirmed recovery address can be told apart from one that was merely typed into the signup
+    form.  Set in doMiddle() after the auto-upgrade; FALSE on a mirror where the ALTER failed. */
 
 /* for earlyBotCheck() function at the beginning of main() */
 #define delayFraction   1.0    /* standard penalty is 1.0 for most CGIs */
 
 /* Forward declarations for functions used before their definitions. */
 static void printSocialButtons(boolean dividerAbove, boolean dividerBelow, char *action);
 static void printEmailLinkButton();
 static boolean emailLinkEnabled();
 static void printUsernameNote();
 void emailLinkPage(struct sqlConnection *conn);
 void displayLoginPage(struct sqlConnection *conn);
 void displayAccHelpPage(struct sqlConnection *conn);
 void completeAccountPage(struct sqlConnection *conn);
 void sendEmailLink(struct sqlConnection *conn);
 
@@ -506,40 +509,62 @@
 
 void mailUsername(char *email, char *users)
 /* send user name list to the email address */
 {
 char subject[256];
 char msg[4096];
 char *remoteAddr=getenv("REMOTE_ADDR");
 
 safef(subject, sizeof(subject),"Your username at the %s", brwName);
 safef(msg, sizeof(msg), 
     "  Someone (probably you, from IP address %s) has requested username(s) associated with this email address at the %s: \n\n  %s\n\n%s\n%s", 
    remoteAddr, brwName, users, signature, returnAddr);
 sendMailOut(email, subject, msg);
 }
 
+static char *sqlAddressMatch(char *email)
+/* Return a SQL fragment matching the gbMembers rows that belong to whoever controls email: the
+ * accounts carrying it as their primary address, plus the accounts carrying it as a *confirmed*
+ * recovery address.  An unconfirmed recovEmail is only a string that a signup form typed in --
+ * nobody ever proved they can read mail there -- so matching it would let someone who registered
+ * with a victim's address as their recovery address capture that victim's login (see
+ * confirmRecovEmail).  Callers must pass a non-empty email, or rows with a blank recovEmail
+ * match.  Result is allocd and carries the sqlSafef prefix; embed it with %-s. */
+{
+struct dyString *dy = sqlDyStringCreate("(email='%s'", email);
+if (recovEmailVerifyOk)
+    sqlDyStringPrintf(dy, " OR (recovEmail='%s' AND recovEmailVerified='Y')", email);
+else
+    /* A mirror whose gbMembers predates the column: we cannot tell confirmed from unconfirmed,
+     * so keep the old behavior rather than locking those users out of their own accounts. */
+    sqlDyStringPrintf(dy, " OR recovEmail='%s'", email);
+sqlDyStringPrintf(dy, ")");
+return dyStringCannibalize(&dy);
+}
+
 void sendUsername(struct sqlConnection *conn, char *email)
 /* email user username(s)  */
 {
 struct sqlResult *sr;
 char **row;
-char query[256];
+char query[1024];
 
 /* find all the user names associated with this email address */
 char userList[512]="";
-sqlSafef(query,sizeof(query),"SELECT * FROM gbMembers WHERE email='%s' or recovEmail='%s'", email, email);
+char *addrMatch = sqlAddressMatch(email);
+sqlSafef(query,sizeof(query),"SELECT * FROM gbMembers WHERE %-s", addrMatch);
+freeMem(addrMatch);
 sr = sqlGetResult(conn, query);
 int numUser = 0;
 while ((row = sqlNextRow(sr)) != NULL)
     {
     struct gbMembers *m = gbMembersLoad(row);
     if (numUser >= 1)
         safecat(userList, sizeof(userList), ", ");
     safecat(userList, sizeof(userList), m->userName);
     numUser += 1;
     }
 sqlFreeResult(&sr);
 mailUsername(email, userList);
 }
 
 void sendPwdMailOut(char *email, char *recovEmail, char *subject, char *msg, char *username)
@@ -658,30 +683,38 @@
 /* email user new password  */
 {
 char query[256];
 /* find email address associated with this username */
 sqlSafef(query,sizeof(query),"SELECT email FROM gbMembers WHERE userName='%s'", username);
 char *email = sqlQuickString(conn, query);
 
 if (!email || sameString(email,""))
     {
     freez(&errMsg);
     errMsg = cloneString("Email address not found.");
     displayAccHelpPage(conn);
     return;
     }
 
+/* Only a confirmed recovery address gets a copy: an unconfirmed one never proved it belongs to
+ * this account, and a new password must not be mailed to a stranger whose address someone typed
+ * into the signup form. */
+if (recovEmailVerifyOk)
+    sqlSafef(query,sizeof(query),
+        "SELECT recovEmail FROM gbMembers WHERE userName='%s' AND recovEmailVerified='Y'",
+        username);
+else
     sqlSafef(query,sizeof(query),"SELECT recovEmail FROM gbMembers WHERE userName='%s'", username);
 char *recovEmail = sqlQuickString(conn, query);
 
 sendNewPwdMail(username, email, recovEmail, password);
 }
 
 void lostPassword(struct sqlConnection *conn, char *username)
 /* Generate and mail new password to user */
 {
 char query[256];
 char *password = generateRandomPassword();
 char encPwd[45] = "";
 encryptNewPwd(password, encPwd, sizeof(encPwd));
 sqlSafef(query,sizeof(query), "UPDATE gbMembers SET lastUse=NOW(),newPassword='%s', newPasswordExpire=DATE_ADD(NOW(), INTERVAL 7 DAY), passwordChangeRequired='Y' WHERE userName='%s'",
     encPwd, username);
@@ -1069,30 +1102,83 @@
  * change was not theirs and can ask us to undo it.  This is the notice that protects the current
  * owner -- confirming the new address only proves the new mailbox is reachable. */
 {
 char subject[256];
 safef(subject, sizeof(subject), "Your %s email address was changed", brwName);
 char *remoteAddr = getenv("REMOTE_ADDR");
 char message[4096];
 safef(message, sizeof(message),
     "The email address on the %s account \"%s\" was just changed to %s (request from IP address "
     "%s).\n\nIf you made this change, nothing more is needed.  If you did NOT, please reply to "
     "this message right away so we can help you secure the account.\n\n%s\n%s",
     brwName, user, newEmail, emptyForNull(remoteAddr), signature, returnAddr);
 sendActMailOut(oldEmail, subject, message);
 }
 
+static char *recovEmailSig(char *user, char *recovEmail, char *verified, char *expStr)
+/* HMAC-MD5 over a pending recovery-address confirmation, keyed by the secret login.cookieSalt.
+ * It goes in the link mailed to the address, so that opening the link -- and only opening it --
+ * marks the address confirmed, proving the mailbox really does reach the person who claimed it.
+ * verified is the account's recovEmailVerified value when the link was minted; because
+ * confirmRecovEmail recomputes the signature from the value currently on the account, a link
+ * stops validating once it has been used, so each link works exactly once.  Result is allocd. */
+{
+char *salt = cfgOption(CFG_LOGIN_COOKIE_SALT);
+if (isEmpty(salt))
+    errAbort("Confirming a recovery email address requires %s in hg.conf, set to a secret random "
+        "string.  Without a secret we cannot sign the confirmation link.", CFG_LOGIN_COOKIE_SALT);
+char buf[1024];
+safef(buf, sizeof(buf), "recovEmail|%s|%s|%s|%s",
+    emptyForNull(user), emptyForNull(recovEmail), emptyForNull(verified), emptyForNull(expStr));
+return hmacMd5(salt, buf);
+}
+
+static void sendRecovEmailConfirmMail(char *recovEmail, char *user)
+/* Email a one-time link to recovEmail that, when opened, marks it confirmed for account user.
+ * Until that happens the address counts for nothing: it cannot sign anyone in and it gets no
+ * copy of a password reset.  The link lasts a week, like the account activation mail, because a
+ * recovery mailbox is often not the one its owner reads every day. */
+{
+char expStr[32];
+safef(expStr, sizeof(expStr), "%ld", clock1() + 7*24*3600);   // link good for a week
+char *sig = recovEmailSig(user, recovEmail, "N", expStr);
+char url[1024];
+safef(url, sizeof(url),
+    "%s?hgLogin.do.confirmRecovEmail=1&user=%s&recovEmail=%s&exp=%s&sig=%s",
+    hgLoginUrl, cgiEncode(user), cgiEncode(recovEmail), expStr, sig);
+char subject[256];
+safef(subject, sizeof(subject), "Confirm your %s recovery email address", brwName);
+char *remoteAddr = getenv("REMOTE_ADDR");
+char message[4096];
+safef(message, sizeof(message),
+    "Someone (probably you, from IP address %s) gave this address as the recovery email address "
+    "for the %s account \"%s\".\nTo confirm that this mailbox is yours, open this link in your "
+    "browser:\n\n%s\n\nThe link works once and expires in seven days.  Until it is opened, this "
+    "address cannot be used to sign in to that account and will not receive a password reset.\n\n"
+    "If this is *not* you, do not open the link: someone typed your address by mistake, and "
+    "ignoring this message is all it takes to keep them from using it.\n\n%s\n%s",
+    emptyForNull(remoteAddr), brwName, user, url, signature, returnAddr);
+/* Not sendActMailOut(): that exits the CGI when the address will not take mail, which would
+ * end the signup response after the account has already been created and its activation mail
+ * sent.  A recovery address is optional and easy to mistype, so a bad one must not derail
+ * signing up -- the address simply stays unconfirmed, which is the safe state. */
+if (mailViaPipeBounce(recovEmail, subject, message, returnAddr) == -1)
+    fprintf(stderr, "hgLogin: could not mail recovery-address confirmation to %s for account "
+        "%s\n", recovEmail, user);
+freeMem(sig);
+}
+
 void changeEmailPage(struct sqlConnection *conn)
 /* Draw the change-email page for the currently logged-in user.  The account is taken from
  * the validated login cookie (wikiLinkUserName), never from a form field, so a user can only
  * change their own email.  Where the account has a password we also ask for it here, so a
  * borrowed login cookie alone cannot change the address (and from there take over the account
  * via password recovery).  Social-login accounts have no password and are asked for none; for
  * them the new address is instead confirmed by email before it takes effect (see changeEmail). */
 {
 if (!emailLinkEnabled())
     {
     displayLoginPage(conn);
     return;
     }
 char *user = wikiLinkUserName();
 if (isEmpty(user))
@@ -1246,30 +1332,87 @@
     }
 sqlSafef(query, sizeof(query),
     "UPDATE gbMembers SET email='%s', lastUse=NOW() WHERE userName='%s'", newEmail, user);
 sqlUpdate(conn, query);
 /* Alert the previous address that the change happened, so a hijack is noticed. */
 if (isNotEmpty(oldEmail) && differentWord(oldEmail, newEmail))
     sendChangeEmailAlertMail(oldEmail, user, newEmail);
 char *encEmail = htmlEncode(newEmail);
 hPrintf("<div class=\"centeredContainer formBox\"><h2>%s</h2>", brwName);
 hPrintf("<h3>Your email address has been changed.</h3>");
 hPrintf("<p>Your email address is now <b>%s</b>.</p></div>", encEmail);
 freeMem(encEmail);
 returnToURL(1500);
 }
 
+void confirmRecovEmail(struct sqlConnection *conn)
+/* Mark a recovery address confirmed.  Reached by opening the signed link mailed to that address
+ * (see sendRecovEmailConfirmMail); the signature and its expiry are the authorization, so this
+ * needs no login cookie -- the link may be opened from that mailbox in any browser. */
+{
+if (!recovEmailVerifyOk || isEmpty(cfgOption(CFG_LOGIN_COOKIE_SALT)))
+    {
+    /* No column to record the answer in, or no secret to check the signature against, so the
+     * link cannot have come from us.  Checked before recovEmailSig(), which aborts without a
+     * secret: a link is only ever minted where one is configured, so reaching here means a
+     * hand-made URL and it deserves the ordinary refusal, not an error page. */
+    freez(&errMsg);
+    errMsg = cloneString("This confirmation link is not valid.");
+    displayLoginPage(conn);
+    return;
+    }
+char *user = cgiUsualString("user", "");
+char *recovEmail = cgiUsualString("recovEmail", "");
+char *expStr = cgiUsualString("exp", "");
+char *sig = cgiUsualString("sig", "");
+/* Recompute the signature over the flag currently on the account.  Once confirmed the flag is
+ * 'Y', so re-opening the same link no longer matches: the link works exactly once. */
+char query[1024];
+sqlSafef(query, sizeof(query),
+    "SELECT recovEmailVerified FROM gbMembers WHERE userName='%s' AND recovEmail='%s'",
+    user, recovEmail);
+char *verified = sqlQuickString(conn, query);
+char *expected = recovEmailSig(user, recovEmail, emptyForNull(verified), expStr);
+boolean sigOk = isNotEmpty(sig) && sameString(sig, expected);
+freeMem(expected);
+if (!sigOk || isEmpty(user) || spc_email_isvalid(recovEmail) == 0)
+    {
+    freez(&errMsg);
+    errMsg = cloneString("This confirmation link is not valid or has already been used.");
+    displayLoginPage(conn);
+    return;
+    }
+if (clock1() > atol(expStr))
+    {
+    freez(&errMsg);
+    errMsg = cloneString("This confirmation link has expired.");
+    displayLoginPage(conn);
+    return;
+    }
+sqlSafef(query, sizeof(query),
+    "UPDATE gbMembers SET recovEmailVerified='Y', lastUse=NOW() "
+    "WHERE userName='%s' AND recovEmail='%s'", user, recovEmail);
+sqlUpdate(conn, query);
+char *encEmail = htmlEncode(recovEmail);
+hPrintf("<div class=\"centeredContainer formBox\"><h2>%s</h2>", brwName);
+hPrintf("<h3>Your recovery email address has been confirmed.</h3>");
+hPrintf("<p><b>%s</b> can now be used to sign in to your account and to recover your "
+    "password.</p></div>", encEmail);
+freeMem(encEmail);
+returnToURL(1500);
+}
+
 void signupPage(struct sqlConnection *conn)
 /* draw the signup page */
 {
 hPrintf("<div id=\"signUpBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf(
     "<p>Signing up enables you to save multiple sessions, share your sessions with others via short and stable session links and manage previously uploaded custom tracks and track hubs.</p>"
     "\n");
 hPrintf("<p>Already have an account? "
     "<a href=\"%s?hgLogin.do.displayLoginPage=1\">Go to the login page</a>.</p>", hgLoginUrl);
 printSocialButtons(FALSE, TRUE, "Sign up");
 hPrintf("<h3>Sign Up Using Email</h3>"
     "<form method=\"post\" action=\"%s\" name=\"mainForm\">"
     "<span style='color:red;'>%s</span>"
     "\n", hgLoginUrl, errMsg ? errMsg : "");
@@ -1284,30 +1427,33 @@
     "<input type=text name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailAddr\">"
     "</div>"
     "\n"
     "<div class=\"inputGroup\">"
     "<label for=\"reenterEmail\">Re-enter email address</label>"
     "<input type=text name=\"hgLogin_email2\" value=\"%s\" size=\"30\" id=\"emailCheck\">"
     "</div>\n",
     htmlEncode(cartUsualString(cart, "hgLogin_userName", "")),   // all three go into value="" attributes; escape (XSS)
     htmlEncode(cartUsualString(cart, "hgLogin_email", "")),
     htmlEncode(cartUsualString(cart, "hgLogin_email2", "")));
 
 if (sqlFieldIndex(conn, "gbMembers", "recovEmail") != -1)
     hPrintf("<div class=\"inputGroup\">"
         "<label for=\"recovEmail\">Optional secondary recovery email</label>"
         "<input type=text name=\"hgLogin_recovEmail\" size=\"30\" id=\"recovEmail\">"
+        "<p style=\"font-size:0.9em\">We will email this address a link to confirm it. Until "
+        "you open that link, the address cannot be used to sign in or to recover your "
+        "password.</p>"
         "</div>"
         "\n");
 
 hPrintf("<div class=\"inputGroup\">"
     "<label for=\"password\">Password <small>(must be at least 5 characters)</small></label>"
     "<span style=\"display:inline-flex; align-items:center;\">"
     "<input type=password name=\"hgLogin_password\" value=\"%s\" size=\"30\" id=\"password\">",
     htmlEncode(cartUsualString(cart, "hgLogin_password", "")));  // value="" attribute; escape (XSS)
 printPwdEyeIcon("signupPwEyeIcon", "signupPwEyeSlash");
 hPrintf(
     "</span>"
     "</div>"
     "\n"
     "<div class=\"inputGroup\">"
     "<label for=\"passwordCheck\">Re-enter password</label>"
@@ -1450,89 +1596,103 @@
     }
 
 /* pass all the checks, OK to create the account now */
 char encPwd[45] = "";
 encryptNewPwd(password, encPwd, sizeof(encPwd));
 char *accActStatus = "N";
 
 if (sameWord(returnAddr, "NOEMAIL"))
     accActStatus = "Y";
 
 struct dyString *query2 = sqlDyStringCreate(
     "INSERT INTO gbMembers SET "
     "userName='%s',realName='%s',password='%s',email='%s',"
     "lastUse=NOW(),accountActivated='%s'",
     user,user,encPwd,email,accActStatus);
+/* A recovery address is confirmed by mail before it counts for anything (see
+ * sendRecovEmailConfirmMail).  Two kinds of install cannot confirm anything: one that sends no
+ * mail at all, and one with no login.cookieSalt to sign the link with (plain login does not
+ * need the salt, so an install can run happily without one).  There, leave the address as
+ * usable as it is today rather than storing one that could never be confirmed. */
+boolean confirmRecov = !isEmpty(recovEmail) && recovEmailVerifyOk
+                        && !sameWord(returnAddr, "NOEMAIL")
+                        && isNotEmpty(cfgOption(CFG_LOGIN_COOKIE_SALT));
 // set the recov email only if we got one (and we only got one if the table has this field)
 if (!isEmpty(recovEmail))
     sqlDyStringPrintf(query2, ",recovEmail='%s'", recovEmail);
+if (confirmRecov)
+    sqlDyStringPrintf(query2, ",recovEmailVerified='N'");
 
 sqlUpdate(conn, dyStringContents(query2));
 dyStringFree(&query2);
 
 if (sameWord(returnAddr, "NOEMAIL"))
     {
     redirectToLoginPage("hgLogin.do.displayLoginPage=1");
     return;
     }
 
 setupNewAccount(conn, email, user);
+if (confirmRecov)
+    sendRecovEmailConfirmMail(recovEmail, user);
 /* send out activate code mail, and display the mail confirmation box */
 cartRemove(cart, "hgLogin_email");
 cartRemove(cart, "hgLogin_email2");
 cartRemove(cart, "hgLogin_userName");
 cartRemove(cart, "user");
 cartRemove(cart, "token");
 redirectToLoginPage("hgLogin.do.displayActMailSuccess=1");
 }
 
 void accountHelp(struct sqlConnection *conn)
 /* email user username(s) or new password */
 {
-char query[256];
+char query[1024];   // room for an address-matching clause holding a long address twice
 char *email = cartUsualString(cart, "hgLogin_email", "");
 char *username = cartUsualString(cart, "hgLogin_userName", "");
 char *helpWith = cartUsualString(cart, "hgLogin_helpWith", "");
 
 /* Passwordless email login link */
 if (sameString(helpWith,"loginLink"))
     {
     sendEmailLink(conn);
     return;
     }
 
 /* Forgot username */
 if (sameString(helpWith,"username"))
     {
     if (sameString(email,""))
         {
         freez(&errMsg);
         errMsg = cloneString("Email address cannot be blank.");
         displayAccHelpPage(conn);
         return;
         }
     else if (spc_email_isvalid(email) == 0)
         {
         freez(&errMsg);
         errMsg = cloneString("Invalid email address format.");
         displayAccHelpPage(conn);
         return;
         }
     else 
         {
+        char *addrMatch = sqlAddressMatch(email);
         sqlSafef(query,sizeof(query),
-            "SELECT password FROM gbMembers WHERE email='%s' or recovEmail='%s'", email, email);
+            "SELECT password FROM gbMembers WHERE %-s", addrMatch);
+        freeMem(addrMatch);
         char *password = sqlQuickString(conn, query);
         cartSetString(cart, "hgLogin_sendMailTo", email);
         cartSetString(cart, "hgLogin_sendMailContain", "username(s)");
         if (!password) /* Email address not found */
             {
             displayMailSuccess();
             return;
             }
         sendUsername(conn, email);
         return;
         }
     }
 /* Forgot password */
 if (sameString(helpWith,"password"))
     {
@@ -2079,45 +2239,45 @@
     {
     // The email-link chooser must not run where passwordless login is switched off.
     displayLoginPage(conn);
     return;
     }
 char *email = emailMode ? cartUsualString(cart, "emailLogin_email", "")
                         : cartUsualString(cart, "oauth_pending_email", "");
 if (isEmpty(email) || (!emailMode && !pendingIdentityValid()))
     {
     if (!emailMode)
         clearPendingIdentity();
     displayLoginPage(conn);
     return;
     }
 char *encEmail = htmlEncode(email);   // the address is displayed; never trust it raw (XSS)
-char query[512];
+char query[1024];
+char *addrMatch = sqlAddressMatch(email);   // email is non-empty here (checked above)
 if (emailMode)
     // Only the accounts that hold the just-validated login token, matching what emailLogin saw.
     sqlSafef(query, sizeof(query),
-        "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' "
+        "SELECT * FROM gbMembers WHERE %-s AND loginToken='%s' "
         "AND loginToken<>'' AND loginTokenExpires > NOW() AND accountActivated='Y' ORDER BY idx",
-        email, email, cartUsualString(cart, "emailLogin_tokenMd5", ""));
+        addrMatch, cartUsualString(cart, "emailLogin_tokenMd5", ""));
 else
     // Only activated accounts, matching what chooseAccount() and resolveIdentity() accept;
     // otherwise the page offers a row the action refuses, and shows the username of an
-    // unactivated row anyone could have created with this address.  Match both primary and
-    // recovery address, as resolveIdentity() does; email is non-empty here (checked above).
+    // unactivated row anyone could have created with this address.
     sqlSafef(query, sizeof(query),
-        "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND accountActivated='Y' "
-        "ORDER BY idx", email, email);
+        "SELECT * FROM gbMembers WHERE %-s AND accountActivated='Y' ORDER BY idx", addrMatch);
+freeMem(addrMatch);
 struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m;
 
 hPrintf("<div id=\"chooseAccountBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf("<h3>Choose an account</h3>");
 if (emailMode)
     hPrintf("<p>The email address <b>%s</b> is associated with more than one %s account. "
         "Select the account you would like to sign in to.</p>", encEmail, brwName);
 else
     hPrintf("<p>The email address <b>%s</b> is associated with more than one %s account. "
         "Select the account you would like to sign in to; your %s login will be linked to it.</p>",
         encEmail, brwName, oauthProviderLabel(provider));
 hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : "");
 hPrintf("<form method=\"post\" action=\"%s\" name=\"chooseAccountForm\">", hgLoginUrl);
 hPrintf("<div class=\"inputGroup\">");
@@ -2138,95 +2298,97 @@
     " &nbsp;<a href=\"%s\" class=\"cancelButton\">Cancel</a>"
     "</div></form></div><!-- END - chooseAccountBox -->", getReturnToUrlForAttr());
 cartSaveSession(cart);
 freeMem(encEmail);
 gbMembersFreeList(&list);
 }
 
 void chooseAccount(struct sqlConnection *conn)
 /* Finish the "which account?" chooser: for OAuth, link the pending identity to the chosen
  * account; for the email link, just sign in.  Either way, only accept an account that really
  * matches the verified email (and, for the email link, still holds the valid token), never an
  * arbitrary username the client might submit. */
 {
 int chosenIdx = cartUsualInt(cart, "hgLogin_chosenIdx", 0);
 char *provider = cartUsualString(cart, "oauth_pending_provider", "");
-char query[512];
+char query[1024];   // room for an address-matching clause holding a long address twice
 
 if (isEmpty(provider))
     {
     /* Passwordless email-link mode. */
     if (!emailLinkEnabled())
         {
         displayLoginPage(conn);
         return;
         }
     char *email = cartUsualString(cart, "emailLogin_email", "");
     char *tokenMd5 = cartUsualString(cart, "emailLogin_tokenMd5", "");
     if (isEmpty(email) || isEmpty(tokenMd5))
         {
         freez(&errMsg);
         errMsg = cloneString("Your login link expired. Please request a new one.");
         displayLoginPage(conn);
         return;
         }
+    char *addrMatch = sqlAddressMatch(email);
     sqlSafef(query, sizeof(query),
-        "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') "
+        "SELECT * FROM gbMembers WHERE idx=%d AND %-s "
         "AND loginToken='%s' AND loginToken<>'' AND loginTokenExpires > NOW() "
         "AND accountActivated='Y'",
-        chosenIdx, email, email, tokenMd5);
+        chosenIdx, addrMatch, tokenMd5);
     struct gbMembers *m = gbMembersLoadByQuery(conn, query);
     if (m == NULL)
         {
         freez(&errMsg);
         errMsg = cloneString("Please choose one of the listed accounts.");
         chooseAccountPage(conn);
         return;
         }
     /* Consume the token on every account that shared it (single use), then sign in.
      * loginAndReturn records the sign-in on the chosen account in gbMembers.lastUse. */
     sqlSafef(query, sizeof(query),
-        "UPDATE gbMembers SET loginToken='' WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s'",
-        email, email, tokenMd5);
+        "UPDATE gbMembers SET loginToken='' WHERE %-s AND loginToken='%s'",
+        addrMatch, tokenMd5);
+    freeMem(addrMatch);
     sqlUpdate(conn, query);
     cartRemove(cart, "emailLogin_email");
     cartRemove(cart, "emailLogin_tokenMd5");
     cartRemove(cart, "hgLogin_chosenIdx");
     loginAndReturn(conn, m->userName, m->idx);
     gbMembersFree(&m);
     return;
     }
 
 /* OAuth mode. */
 char *subject = cartUsualString(cart, "oauth_pending_subject", "");
 char *email = cartUsualString(cart, "oauth_pending_email", "");
 if (isEmpty(subject) || isEmpty(email) || !pendingIdentityValid())
     {
     clearPendingIdentity();
     freez(&errMsg);
     errMsg = cloneString("Your login session expired. Please sign in again.");
     displayLoginPage(conn);
     return;
     }
 /* Only an activated account counts: an unactivated row can hold any address someone typed
  * without ever proving they own it (see resolveIdentity), so it must not receive a social link.
- * Match both primary and recovery address, as chooseAccountPage() offers; email is non-empty
- * here (checked above). */
+ * Match what chooseAccountPage() offers; email is non-empty here (checked above). */
+char *oauthAddrMatch = sqlAddressMatch(email);
 sqlSafef(query, sizeof(query),
-    "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') "
-    "AND accountActivated='Y'",
-    chosenIdx, email, email);
+    "SELECT * FROM gbMembers WHERE idx=%d AND %-s AND accountActivated='Y'",
+    chosenIdx, oauthAddrMatch);
+freeMem(oauthAddrMatch);
 struct gbMembers *m = gbMembersLoadByQuery(conn, query);
 if (m == NULL)
     {
     freez(&errMsg);
     errMsg = cloneString("Please choose one of the listed accounts.");
     chooseAccountPage(conn);
     return;
     }
 struct oauthIdentity pending;
 ZeroVar(&pending);
 pending.provider = provider;
 pending.subject = subject;
 pending.email = email;
 linkIdentity(conn, m->idx, &pending);
 clearPendingIdentity();
@@ -2239,43 +2401,44 @@
 /* Log in the user behind an authenticated provider identity:
  *  1. If the provider gave a verified email matching MORE THAN ONE account, always let the
  *     user pick which one -- even if this identity was linked before. Because login cookies
  *     never expire, a user goes through OAuth very rarely, so an occasional pick is cheap
  *     and it lets a person with several same-email accounts choose freely each time.
  *  2. Else if the (provider,subject) is already linked, log into that account.
  *  3. Else if the verified email matches exactly one account, auto-link and log in.
  *  4. Else send the user to the "choose a username" page to finish a new account.
  * (Providers that don't release an email, e.g. ORCID, never reach step 1 or 3 and rely on
  *  the stored link from step 2.) */
 {
 struct gbMembers *matches = NULL;
 int n = 0;
 if (id->emailVerified && isNotEmpty(id->email))
     {
-    char query[512];
-    /* Match the provider email against both the primary and the recovery address, the same as
-     * password and email-link login do (see sendUsername/emailLogin).  The isNotEmpty() guard
-     * above keeps id->email out of the query, so a blank recovEm=='' row can never match.
+    char query[1024];
+    /* Match the provider email against the primary address and any confirmed recovery address,
+     * the same as password and email-link login do (see sqlAddressMatch).  The isNotEmpty()
+     * guard above keeps an empty id->email out of the query, so a blank recovEmail='' row can
+     * never match.
      * Match only activated accounts.  gbMembers has no unique key on email, and the plain
      * signup form will create an unactivated row for any address a person types -- the
      * activation mail goes to the address's real owner, who ignores it.  Without this filter
      * someone could pre-register a victim's address, and the victim's first social login would
      * then auto-link to (and sign in as) the attacker's account. */
+    char *addrMatch = sqlAddressMatch(id->email);
     sqlSafef(query, sizeof(query),
-        "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND accountActivated='Y' "
-        "ORDER BY idx",
-        id->email, id->email);
+        "SELECT * FROM gbMembers WHERE %-s AND accountActivated='Y' ORDER BY idx", addrMatch);
+    freeMem(addrMatch);
     matches = gbMembersLoadByQuery(conn, query);
     n = slCount(matches);
     }
 
 if (n > 1)
     {
     setPendingIdentity(id);
     gbMembersFreeList(&matches);
     chooseAccountPage(conn);
     return;
     }
 
 struct gbMembers *linked = memberForIdentity(conn, id);
 if (linked != NULL)
     {
@@ -2450,34 +2613,35 @@
 /* Generate and email a one-time passwordless login link to the address on file. */
 {
 if (!emailLinkEnabled())
     {
     displayLoginPage(conn);
     return;
     }
 char *email = cartUsualString(cart, "hgLogin_email", "");
 if (isEmpty(email) || spc_email_isvalid(email) == 0)
     {
     freez(&errMsg);
     errMsg = cloneString("Please enter a valid email address.");
     emailLinkPage(conn);
     return;
     }
-char query[512];
+char query[1024];
+char *addrMatch = sqlAddressMatch(email);
 sqlSafef(query, sizeof(query),
-    "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND accountActivated='Y'",
-    email, email);
+    "SELECT * FROM gbMembers WHERE %-s AND accountActivated='Y'", addrMatch);
+freeMem(addrMatch);
 struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m;
 if (list != NULL)
     {
     /* One token for the address, stored on every account that uses it, and one email.
      * The user proves they own the address by clicking; only then (in emailLogin) do we
      * reveal the accounts and let them choose, so we never disclose accounts to someone
      * who merely typed the address here. */
     char *token = makeRandomKey(128+33);
     char *tokenMD5 = generateTokenMD5(token);
     for (m = list; m != NULL; m = m->next)
         {
         sqlSafef(query, sizeof(query),
             "UPDATE gbMembers SET loginToken='%s', "
             "loginTokenExpires=DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE idx=%u",
             tokenMD5, m->idx);
@@ -2493,35 +2657,37 @@
 }
 
 void emailLogin(struct sqlConnection *conn)
 /* Validate a one-time email login token.  The token proves the user owns the address; if it
  * matches one account, log straight in; if it matches several accounts that share the
  * address, show the account chooser (the same one the OAuth flow uses). */
 {
 if (!emailLinkEnabled())
     {
     displayLoginPage(conn);
     return;
     }
 char *email = cgiUsualString("email", "");
 char *token = cgiUsualString("token", "");
 char *tokenMD5 = generateTokenMD5(token);
-char query[512];
+char query[1024];
+char *addrMatch = sqlAddressMatch(email);
 sqlSafef(query, sizeof(query),
-    "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' "
+    "SELECT * FROM gbMembers WHERE %-s AND loginToken='%s' "
     "AND loginToken<>'' AND loginTokenExpires > NOW() AND accountActivated='Y' ORDER BY idx",
-    email, email, tokenMD5);
+    addrMatch, tokenMD5);
+freeMem(addrMatch);
 struct gbMembers *list = gbMembersLoadByQuery(conn, query);
 int n = slCount(list);
 if (n == 0)
     {
     freez(&errMsg);
     errMsg = cloneString("This login link is invalid or has expired. Please request a new one.");
     displayLoginPage(conn);
     }
 else if (n == 1)
     {
     sqlSafef(query, sizeof(query),
         "UPDATE gbMembers SET loginToken='' WHERE idx=%u", list->idx);
     sqlUpdate(conn, query);
     loginAndReturn(conn, list->userName, list->idx);
     }
@@ -2559,30 +2725,38 @@
         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
 if (sqlFieldIndex(conn, "gbMembers", "recovEmail") == -1) {
     autoUpgradeTableAddColumn(conn, "gbMembers", "recovEmail", "varchar(255)", FALSE, "''");
 }
 
+/* Tells a confirmed recovery address from one that was only typed into the signup form.
+ * Existing rows default to 'Y': every recovery address that predates this column keeps working
+ * exactly as before, so no one has to re-confirm an address they set up long ago.  Only
+ * addresses entered from now on have to be confirmed (see sendRecovEmailConfirmMail). */
+if (sqlFieldIndex(conn, "gbMembers", "recovEmailVerified") == -1)
+    autoUpgradeTableAddColumn(conn, "gbMembers", "recovEmailVerified", "varchar(1)", FALSE, "'Y'");
+recovEmailVerifyOk = (sqlFieldIndex(conn, "gbMembers", "recovEmailVerified") != -1);
+
 // columns for the passwordless email-link login feature
 if (sqlFieldIndex(conn, "gbMembers", "loginToken") == -1)
     autoUpgradeTableAddColumn(conn, "gbMembers", "loginToken", "varchar(255)", FALSE, "NULL");
 if (sqlFieldIndex(conn, "gbMembers", "loginTokenExpires") == -1)
     autoUpgradeTableAddColumn(conn, "gbMembers", "loginTokenExpires", "DATETIME", FALSE, "NULL");
 
 // table linking accounts to social (Google/ORCID) identities; only needed where OAuth is set up
 if (oauthAnyProviderEnabled())
     createIdentityTable(conn);
 
 cart = theCart;
 dropRequestSuppliedFlowVars();
 safecpy(brwName,sizeof(brwName), browserName());
 safecpy(brwAddr,sizeof(brwAddr), browserAddr());
 safecpy(signature,sizeof(signature), mailSignature());
@@ -2606,30 +2780,32 @@
     emailLinkPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.sendEmailLink"))
     sendEmailLink(conn);
 else if (cartVarExists(cart, "hgLogin.do.emailLogin"))
     emailLogin(conn);
 else if (cartVarExists(cart, "hgLogin.do.changePasswordPage"))
     changePasswordPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.changePassword"))
     changePassword(conn);
 else if (cartVarExists(cart, "hgLogin.do.changeEmailPage"))
     changeEmailPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.changeEmail"))
     changeEmail(conn);
 else if (cartVarExists(cart, "hgLogin.do.confirmChangeEmail"))
     confirmChangeEmail(conn);
+else if (cartVarExists(cart, "hgLogin.do.confirmRecovEmail"))
+    confirmRecovEmail(conn);
 else if (cartVarExists(cart, "hgLogin.do.displayAccHelpPage"))
     displayAccHelpPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.accountHelp"))
     accountHelp(conn);
 else if (cartVarExists(cart, "hgLogin.do.activateAccount"))
     activateAccount(conn);
 else if (cartVarExists(cart, "hgLogin.do.displayActMailSuccess"))
     displayActMailSuccess();
 else if (cartVarExists(cart, "hgLogin.do.displayMailSuccess"))
     displayMailSuccess();
 else if (cartVarExists(cart, "hgLogin.do.displayMailSuccessPwd"))
     displayMailSuccessPwd();
 else if (cartVarExists(cart, "hgLogin.do.displayLoginPage"))
     displayLoginPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.displayLogin"))