changeset 5749:5d5ec86e40a2

improve textcha security (thanks to rfw, GCI 2010)
author Thomas Waldmann <tw AT waldmann-edv DOT de>
date Wed, 08 Dec 2010 00:18:35 +0100
parents 666e359493f5
children 021c1f6d3272
files MoinMoin/config/multiconfig.py MoinMoin/security/textcha.py
diffstat 2 files changed, 51 insertions(+), 8 deletions(-) [+]
line wrap: on
line diff
--- a/MoinMoin/config/multiconfig.py	Sun Dec 05 23:01:15 2010 +0100
+++ b/MoinMoin/config/multiconfig.py	Wed Dec 08 00:18:35 2010 +0100
@@ -411,6 +411,8 @@
         secret_key_names = ['action/cache', 'wikiutil/tickets', 'xmlrpc/ProcessMail', 'xmlrpc/RemoteScript', ]
         if self.jabber_enabled:
             secret_key_names.append('jabberbot')
+        if self.textchas:
+            secret_key_names.append('security/textcha')
 
         secret_min_length = 10
         if isinstance(self.secrets, str):
@@ -820,6 +822,8 @@
      "Spam protection setup using site-specific questions/answers, see HelpOnSpam."),
     ('textchas_disabled_group', None,
      "Name of a group of trusted users who do not get asked !TextCha questions."),
+    ('textchas_expiry_time', 600,
+     "Time [s] for a !TextCha to expire."),
 
     ('antispam_master_url', "http://master.moinmo.in/?action=xmlrpc2",
      "where antispam security policy fetches spam pattern updates (if it is enabled)"),
--- a/MoinMoin/security/textcha.py	Sun Dec 05 23:01:15 2010 +0100
+++ b/MoinMoin/security/textcha.py	Wed Dec 08 00:18:35 2010 +0100
@@ -12,7 +12,6 @@
 
     TODO:
     * roundtrip the question in some other way:
-     * use safe encoding / encryption for the q
      * make sure a q/a pair in the POST is for the q in the GET before
     * make some nice CSS
     * make similar changes to GUI editor
@@ -24,10 +23,16 @@
 import re
 import random
 
+from time import time
+
 from MoinMoin import log
 logging = log.getLogger(__name__)
 
 from MoinMoin import wikiutil
+from MoinMoin.support.python_compatibility import hmac_new
+
+SHA1_LEN = 40 # length of hexdigest
+TIMESTAMP_LEN = 10 # length of timestamp
 
 class TextCha(object):
     """ Text CAPTCHA support """
@@ -41,6 +46,9 @@
         self.request = request
         self.user_info = request.user.valid and request.user.name or request.remote_addr
         self.textchas = self._get_textchas()
+        if self.textchas:
+            self.secret = request.cfg.secrets["security/textcha"]
+            self.expiry_time = request.cfg.textchas_expiry_time
         self._init_qa(question)
 
     def _get_textchas(self):
@@ -74,6 +82,9 @@
             logging.debug(u"TextCha: using lang = '%s'" % lang)
             return textchas[lang]
 
+    def _compute_signature(self, question, timestamp):
+        return hmac_new(self.secret, "%s%d" % (question, timestamp)).hexdigest()
+
     def _init_qa(self, question=None):
         """ Initialize the question / answer.
 
@@ -114,14 +125,22 @@
         """
         return not not self.textchas # we don't want to return the dict
 
-    def check_answer(self, given_answer):
-        """ check if the given answer to the question is correct """
+    def check_answer(self, given_answer, timestamp, signature):
+        """ check if the given answer to the question is correct and within the correct timeframe"""
         if self.is_enabled():
             if self.answer_re is not None:
                 success = self.answer_re.match(given_answer.strip()) is not None
             else:
                 # someone trying to cheat!?
                 success = False
+            if not timestamp or timestamp + self.expiry_time < time():
+                success = False
+            try:
+                if self._compute_signature(self.question, timestamp) != signature:
+                    success = False
+            except TypeError:
+                success = False
+
             success_status = success and u"success" or u"failure"
             logging.info(u"TextCha: %s (u='%s', a='%s', re='%s', q='%s')" % (
                              success_status,
@@ -135,7 +154,12 @@
             return True
 
     def _make_form_values(self, question, given_answer):
-        question_form = wikiutil.escape(question, True)
+        timestamp = time()
+        question_form = "%s %d%s" % (
+            wikiutil.escape(question, True),
+            timestamp,
+            self._compute_signature(question, timestamp)
+        )
         given_answer_form = wikiutil.escape(given_answer, True)
         return question_form, given_answer_form
 
@@ -143,8 +167,23 @@
         if form is None:
             form = self.request.form
         question = form.get('textcha-question')
+        signature = None
+        timestamp = None
+        if question:
+            # the signature is the last SHA1_LEN bytes of the question
+            signature = question[-SHA1_LEN:]
+            
+            # operate on the remainder
+            question = question[:-SHA1_LEN]
+            try:
+                # the timestamp is the next TIMESTAMP_LEN bytes
+                timestamp = int(question[-TIMESTAMP_LEN:])
+            except ValueError:
+                pass
+            # there is a space between the timestamp and the question, so take away 1
+            question = question[:-TIMESTAMP_LEN - 1]
         given_answer = form.get('textcha-answer', u'')
-        return question, given_answer
+        return question, given_answer, timestamp, signature
 
     def render(self, form=None):
         """ Checks if textchas are enabled and returns HTML for one,
@@ -153,7 +192,7 @@
             @return: unicode result html
         """
         if self.is_enabled():
-            question, given_answer = self._extract_form_values(form)
+            question, given_answer, timestamp, signature = self._extract_form_values(form)
             if question is None:
                 question = self.question
             question_form, given_answer_form = self._make_form_values(question, given_answer)
@@ -170,9 +209,9 @@
 
     def check_answer_from_form(self, form=None):
         if self.is_enabled():
-            question, given_answer = self._extract_form_values(form)
+            question, given_answer, timestamp, signature = self._extract_form_values(form)
             self._init_qa(question)
-            return self.check_answer(given_answer)
+            return self.check_answer(given_answer, timestamp, signature)
         else:
             return True