b6f15357f432f1c218fabcb0d53777322e357c00
max
  Tue Sep 29 15:43:57 2026 -0700
hgGeneGraph: bind the feedback-form arithmetic check to its target and a real secret

Reuses a per-render hash instead of a fixed public constant, and ties it to
the specific gene pair rather than just the answer, refs #38399

diff --git src/hg/hgGeneGraph/hgGeneGraph src/hg/hgGeneGraph/hgGeneGraph
index 0f87b36bf9d..5fcc08b921c 100755
--- src/hg/hgGeneGraph/hgGeneGraph
+++ src/hg/hgGeneGraph/hgGeneGraph
@@ -318,30 +318,39 @@
     if style!=None:
         styleStr='style="%s" ' % style
 
     titleStr = ""
     if title!=None:
         titleStr = ' title="%s"' % title.replace('"', ' ')
     return '<a %s%s%shref="%s"%s>%s</a>' % (titleStr, classStr, dataToggleStr, url, styleStr, linkName)
 
 def saltedHash(word, length=5):
     " return first 5 chars of salted hash "
     # pretty simple salt: PITX2, salting is just for the captcha
     inStr = word+"PITX2"
     hashStr = "".join(hashlib.sha1(inStr.encode("utf8")).hexdigest()[:length]).lower()
     return hashStr
 
+def captchaHashFor(param, answer):
+    """ Hash of an arithmetic-captcha answer, bound to the specific removal target and a
+    secret from hg.conf.private, so one solved answer is only good for the one form it was
+    shown on.  param is length-prefixed so a differently-split (param, answer) pair cannot
+    concatenate to the same bytes and collide. """
+    secret = cfgOption("hgGeneGraph.captchaSecret", "PITX2")
+    inStr = "%s%d:%s%s" % (secret, len(param), param, answer)
+    return hashlib.sha1(inStr.encode("utf8")).hexdigest()[:10]
+
 
 def reqMinSupp(links, minArtSupp, maxResCount, targetGene):
     """ remove all 'text mining only' links with less than minArtSupp supporting documents
         The only exception is targetGene which we always want to stay connected
 
         Also remove links that are PPI-only and have a high minResCount.
     """
     newLinks = defaultdict(set)
     genes = set()
     targetConns = {}
     for genePair, linkData in links.items():
         docCount, dbCount, tagSet, pairMinResCount = linkData[:4]
         if targetGene in genePair:
             targetConns[genePair] = linkData
         # remove text-mining links with only one article
@@ -1882,65 +1891,65 @@
 
     conn = sqlConnect(GGDB)
     rows = queryEventText(conn, causeGene, themeGene)
 
     print ("<p>")
     print ("Thank you for reporting errors, e.g.")
     print("<ul><li>not true (e.g. the authors state that the interaction does not happen )</li>")
     print("<li>text mining errors (the authors did not say anything about this particular interaction)</li>")
     print("</ul>")
     print("This makes it easier for us to improve the text mining system or database imports.<br>")
     print("You can leave your email address in the comment if you want to give us the possibility to comment.<P>")
 
     print ("Optional comment: <br>")
     print(('<FORM method="POST" action="%s">' % basename(__file__)))
     print(('<INPUT TYPE="HIDDEN" NAME="remove" VALUE="%s"></INPUT>' % param))
-    # A short arithmetic question, checked back in removeInteraction() against the salted
-    # hash of its answer, keeps this write-capable form from being submitted from anywhere
-    # but this page.
+    # A short arithmetic question, checked back in removeInteraction() against a hash of its
+    # answer and this target, keeps this write-capable form from being submitted from anywhere
+    # but this page, or replayed against a different target.
     numA = random.randint(1, 9)
     numB = random.randint(1, 9)
-    print(('<INPUT TYPE="HIDDEN" NAME="captchaHash" VALUE="%s"></INPUT>' % saltedHash(str(numA+numB))))
+    print(('<INPUT TYPE="HIDDEN" NAME="captchaHash" VALUE="%s"></INPUT>' % captchaHashFor(param, str(numA+numB))))
     print ('<TEXTAREA rows="6" cols="50" name="comment"></TEXTAREA>')
     print ("<P>")
     print ('What is %d + %d? <INPUT TYPE="TEXT" NAME="captcha" SIZE="4"></INPUT></P>' % (numA, numB))
 
     print ('<INPUT TYPE="SUBMIT" name="submit" value="Report Interaction as inaccurate"></INPUT>')
     print ('</FORM>')
     #printMsrNlpRows(rows)
     showSnipsLink(conn, causeGene, themeGene)
 
 #def namedtuple_factory(cursor, row):
     #"""
     #used as a function pointer to sqlite to have it return structs with names and not just arrays
     #"""
     #fields = [col[0] for col in cursor.description]
     #Row = collections.namedtuple("Row", fields)
     #return Row(*row)
 
 #def openSqlite(dbName):
     #" opens sqlite database and have it return structs (=namedtuples) "
     #tryCount = retries
     #con = None
     #con = sqlite3.connect(dbName, timeout=20)
     #con.row_factory = namedtuple_factory
     ##con.row_factory = sqlite3.Row
 
 def removeInteraction(param, captcha, captchaHash, comment):
     if os.environ.get("REQUEST_METHOD") != "POST":
         errAbort("This action requires a form submission, not a link.")
-    if captcha is None or captchaHash is None or saltedHash(captcha.strip()) != captchaHash:
+    if captcha is None or captchaHash is None or captchaHashFor(param, captcha.strip()) != captchaHash:
         errAbort("The answer to the arithmetic question was not correct. Please go back and try again.")
 
     fields = param.split(":")
     if len(fields)!=2:
         errAbort( "illegal CGI parameter")
 
     causeGene, themeGene = fields
     ip = html.escape(os.environ["REMOTE_ADDR"])
 
     conn = hConnectCentral()
     if not sqlTableExists(conn, "ggFeedback"):
         # sqlQuery() reads cursor.description, which is None after a statement that returns no
         # rows, so it cannot run a CREATE
         sqlUpdate(conn, "CREATE TABLE ggFeedback (causeGene varchar(255), themeGene varchar(255), pmid varchar(255), " \
                 "comment varchar(10000), time TIMESTAMP, ip varchar(255), INDEX allIdx (causeGene, themeGene, pmid));")