Commits

mitsuhiko  committed a8779c4

Added hugely improved CSRF validation to the forms. This time also for
users that are not logged in!

  • Participants
  • Parent commits 59d9b58

Comments (0)

Files changed (8)

File solace/application.py

 from fnmatch import fnmatch
 from functools import update_wrapper
 from simplejson import dumps
+try:
+    from hashlib import sha1
+except ImportError:
+    from sha import new as sha1
 
 from babel import UnknownLocaleError, Locale
 from werkzeug import Request as RequestBase, Response, cached_property, \
     """The request class."""
 
     in_api = False
+    csrf_protected = False
     _locale = None
     _pulled_flash_messages = None
 
             self._pulled_flash_messages = msgs
         return msgs
 
+    def get_csrf_token(self, force_generation=False):
+        """Return the CSRF token."""
+        token = self.session.get('csrf_token')
+        if token is None or force_generation:
+            token = sha1(os.urandom(12)).hexdigest()[:24]
+            self.session['csrf_token'] = token
+        return token
+
+    def clear_csrf_token(self):
+        """Clears the CSRF token."""
+        self.session.pop('csrf_token', None)
+
 
 def get_view(endpoint):
     """Returns the view for the endpoint."""

File solace/forms.py

     password = forms.TextField(lazy_gettext(u'Password'), required=True,
                                widget=forms.PasswordInput)
 
-    def __init__(self, initial=None, action=None):
-        forms.Form.__init__(self, initial, action)
+    def __init__(self, initial=None, action=None, request=None):
+        forms.Form.__init__(self, initial, action, request)
         self.auth_system = get_auth_system()
         if self.auth_system.passwordless:
             del self.fields['password']
         """We're protected if the config says so."""
         return settings.RECAPTCHA_ENABLE
 
-    def __init__(self, initial=None, action=None):
-        forms.Form.__init__(self, initial, action)
+    def __init__(self, initial=None, action=None, request=None):
+        forms.Form.__init__(self, initial, action, request)
         self.user = None
 
     def _check_active(self, user):
                             validators=[is_valid_email])
     real_name = forms.TextField(lazy_gettext(u'Real name'))
 
-    def __init__(self, user, initial=None, action=None):
+    def __init__(self, user, initial=None, action=None, request=None):
         self.user = user
         self.auth_system = get_auth_system()
         if self.auth_system.passwordless or \
             if 'email' in self.fields:
                 initial['email'] = user.email
 
-        forms.Form.__init__(self, initial, action)
+        forms.Form.__init__(self, initial, action, request)
 
     def context_validate(self, data):
         password = data.get('password')
         messages=dict(too_big=lazy_gettext(u'You attached too many tags. '
                                            u'You may only use 10 tags.')))
 
-    def __init__(self, topic=None, revision=None, initial=None, action=None):
+    def __init__(self, topic=None, revision=None, initial=None, action=None,
+                 request=None):
         self.topic = topic
         self.revision = revision
         if topic is not None:
             text = (revision or topic.question).text
             initial = forms.fill_dict(initial, title=topic.title,
                                       text=text, tags=[x.name for x in topic.tags])
-        forms.Form.__init__(self, initial, action)
+        forms.Form.__init__(self, initial, action, request)
 
     def create_topic(self, view_lang=None, user=None):
         """Creates a new topic."""

File solace/static/solace.js

   dynamicSubmit : function(selector, callback) {
     $(selector).ajaxSubmit({
       dataType:     'json',
-      success:      Solace._standardRemoteCallback(callback)
+      success:      function(data) {
+        /* if we successfully submitted data, the server will have
+           invalidated the CSRF token.  Assuming we want to submit
+           the form another time, we send another HTTP request to
+           get the updated CSRF token. */
+        var token_field = $('input[name="_csrf_token"]');
+        if (token_field.length)
+          Solace.request('_get_csrf_token', null, 'GET', function(response) {
+            token_field.val(response.token);
+          });
+        return Solace._standardRemoteCallback(callback)(data);
+      }
     });
   },
 

File solace/urls.py

     Rule('/_submit_comment/<post>') > 'kb.submit_comment',
     Rule('/_get_tags/<lang_code>') > 'kb.get_tags',
     Rule('/_no_javascript') > 'core.no_javascript',
+    Rule('/_get_csrf_token') > 'core.get_csrf_token',
     Rule('/_i18n/<lang>.js') > 'core.get_translations',
 
     # the API (version 1.0)

File solace/utils/forms.py

 import string
 from datetime import datetime
 from itertools import chain
+from functools import update_wrapper
 from threading import Lock
-try:
-    from hashlib import sha1
-except ImportError:
-    from sha import new as sha1
 
 from werkzeug import html, escape, MultiDict, redirect, cached_property
 
         if self.form.csrf_protected and self.form.request is not None:
             token = self.form.request.values.get('_csrf_token')
             if token != self.form.csrf_token:
-                raise ValidationError(_(u'Invalid security token submitted.'))
+                raise ValidationError(_(u'Form submitted multiple times or '
+                                        u'session expired.  Try again.'))
         if self.form.captcha_protected:
             request = self.form.request
             if request is None:
     Forms can be recaptcha protected by setting `catcha_protected` to `True`.
     If catpcha protection is enabled the catcha has to be rendered from the
     widget created, like a field.
+
+    Forms are CSRF protected if they are created in the context of an active
+    request or if an request is passed to the constructor.  In order for the
+    CSRF protection to work it will modify the session on the request.
+
+    The consequence of that is that the application must not ignore session
+    changes.
     """
     __metaclass__ = FormMeta
 
-    csrf_protected = True
+    csrf_protected = False
     redirect_tracking = True
     captcha_protected = False
 
-    def __init__(self, initial=None, action=None):
-        self.request = Request.current
+    def __init__(self, initial=None, action=None, request=None):
+        if request is None:
+            request = Request.current
+        self.request = request
         if initial is None:
             initial = {}
         self.initial = initial
         self.action = action
         self.invalid_redirect_targets = set()
+        if self.request is not None:
+            self.csrf_protected = True
 
         self._root_field = _bind(self.__class__._root_field, self, {})
         self.reset()
     @property
     def csrf_token(self):
         """The unique CSRF security token for this form."""
-        if self.request is None:
-            raise AttributeError('no csrf token because form not bound '
-                                 'to request')
-        path = self.action or self.request.path
-        user_id = -1
-        if self.request.is_logged_in:
-            user_id = self.request.user.id
-        key = settings.SECRET_KEY
-        return sha1(('%s|%s|%s' % (path, user_id, key))
-                     .encode('utf-8')).hexdigest()
+        if not self.csrf_protected:
+            raise AttributeError('no csrf token because form not '
+                                 'csrf protected')
+        return self.request.get_csrf_token()
 
     @property
     def is_valid(self):
             seq = self.errors[field] = ErrorList()
         seq.append(error)
 
-    def validate(self, data):
-        """Validate the form against the data passed."""
+    def validate(self, data=None):
+        """Validate the form against the data passed.  If no data is provided
+        the form data of the current request is taken.
+        """
+        if data is None:
+            data = getattr(self.request, 'form', None)
+            if data is None:
+                raise RuntimeError('cannot validate implicitly without '
+                                   'form being bound to request')
         self.raw_data = _decode(data)
 
         # for each field in the root that requires validation on value
         except ValidationError, e:
             errors = e.unpack()
         self.errors = errors
+
+        # every time we validate, we invalidate the csrf token if there
+        # was one.
+        if self.csrf_protected:
+            self.request.clear_csrf_token()
+            self.request.session.pop('csrf_token', None)
+
         if errors:
             return False
 

File solace/views/core.py

 from werkzeug.exceptions import NotFound
 from babel import Locale, UnknownLocaleError
 
-from solace.application import url_for
+from solace.application import url_for, json_response
 from solace.auth import get_auth_system, LoginUnsucessful
 from solace.templating import render_template
 from solace.i18n import _, has_section, get_js_translations
         return rv
 
     form = LoginForm()
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         username = form.data['username']
 
         # watch out, there might not be a password for
         return rv
 
     form = RegistrationForm()
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         rv = auth.register(request, form['username'],
                            form['password'], form['email'])
         session.commit()
     return render_template('core/no_javascript.html')
 
 
+def get_csrf_token(request):
+    """Updates the CSRF token.  Required for forms that are submitted multiple
+    times using JavaScript.  This updates the token.
+    """
+    if not request.is_xhr:
+        raise BadRequest()
+    return json_response(token=request.get_csrf_token(force_generation=True))
+
+
 def get_translations(request, lang):
     """Returns the translations for the given language."""
     rv = get_js_translations(lang)

File solace/views/kb.py

     # a form for the replies.
     form = ReplyForm(topic)
 
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         reply = form.create_reply()
         session.commit()
         request.flash(_(u'Your reply was posted.'))
     """The new-question form."""
     form = QuestionForm()
 
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         topic = form.create_topic()
         session.commit()
         request.flash(_(u'Your question was posted.'))
     else:
         form = ReplyForm(post=post, revision=revision)
 
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         form.save_changes()
         session.commit()
         request.flash(_('The post was edited.'))
         return redirect(url_for(post))
 
     form = EmptyForm()
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         if 'yes' in request.form:
             post.delete()
             session.commit()
         raise Forbidden()
 
     form = EmptyForm()
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         if 'yes' in request.form:
             if revision is None:
                 request.flash(_(u'The post was restored'))
         return json_response(success=False, form_errors=[message])
 
     form = _get_comment_form(post)
-    if form.validate(request.form):
+    if form.validate():
         comment = form.create_comment()
         session.commit()
         comment_box = get_macro('kb/_boxes.html', 'render_comment')

File solace/views/users.py

     """Allows the user to change profile information."""
     form = ProfileEditForm(request.user)
 
-    if request.method == 'POST' and form.validate(request.form):
+    if request.method == 'POST' and form.validate():
         request.flash(_(u'Your profile was updated'))
         form.apply_changes()
         session.commit()