Repository: freedomofpress/securedrop

Issue:
Poor input validation on OTP secret field in journalist interface
## Description

500 error thrown due to lack of validation in new user form

Same issue as #2311, except that issue was for `manage.py`

## Steps to Reproduce

0. Sign in as administrator on journalist interface
1. Try to create new user using a Yubikey and provide a secret with an odd length

## Expected Behavior

Happy error message is shown indicating that the expected length of the secret is 40 characters

## Actual Behavior

![screen shot 2017-10-31 at 3 01 26 pm](https://user-images.githubusercontent.com/7832803/32251032-7280e2e0-be4c-11e7-9de5-10746fd27def.png)

## Comments

Simple fix, check length of string and flash an appropriate error message 
Poor input validation on OTP secret field in journalist interface
## Description

500 error thrown due to lack of validation in new user form

Same issue as #2311, except that issue was for `manage.py`

## Steps to Reproduce

0. Sign in as administrator on journalist interface
1. Try to create new user using a Yubikey and provide a secret with an odd length

## Expected Behavior

Happy error message is shown indicating that the expected length of the secret is 40 characters

## Actual Behavior

![screen shot 2017-10-31 at 3 01 26 pm](https://user-images.githubusercontent.com/7832803/32251032-7280e2e0-be4c-11e7-9de5-10746fd27def.png)

## Comments

Simple fix, check length of string and flash an appropriate error message 


Discussion:

Resolving patch:
diff --git a/securedrop/journalist_app/admin.py b/securedrop/journalist_app/admin.py
--- a/securedrop/journalist_app/admin.py
+++ b/securedrop/journalist_app/admin.py
@@ -11,6 +11,7 @@
 from journalist_app.decorators import admin_required
 from journalist_app.utils import (make_password, commit_account_changes,
                                   set_diceware_password)
+from journalist_app.forms import NewUserForm
 
 
 def make_blueprint(config):
@@ -25,52 +26,52 @@ def index():
     @view.route('/add', methods=('GET', 'POST'))
     @admin_required
     def add_user():
-        if request.method == 'POST':
+        form = NewUserForm()
+        if form.validate_on_submit():
             form_valid = True
             username = request.form['username']
-
             password = request.form['password']
             is_admin = bool(request.form.get('is_admin'))
 
-            if form_valid:
-                try:
-                    otp_secret = None
-                    if request.form.get('is_hotp', False):
-                        otp_secret = request.form.get('otp_secret', '')
-                    new_user = Journalist(username=username,
-                                          password=password,
-                                          is_admin=is_admin,
-                                          otp_secret=otp_secret)
-                    db_session.add(new_user)
-                    db_session.commit()
-                except PasswordError:
-                    flash(gettext(
-                        'There was an error with the autogenerated password. '
-                        'User not created. Please try again.'), 'error')
-                    form_valid = False
-                except InvalidUsernameException as e:
-                    form_valid = False
-                    flash('Invalid username: ' + str(e), "error")
-                except IntegrityError as e:
-                    db_session.rollback()
-                    form_valid = False
-                    if ("UNIQUE constraint failed: journalists.username"
-                            in str(e)):
-                        flash(gettext("That username is already in use"),
-                              "error")
-                    else:
-                        flash(gettext("An error occurred saving this user"
-                                      " to the database."
-                                      " Please inform your administrator."),
-                              "error")
-                        current_app.logger.error("Adding user '{}' failed: {}"
-                                                 .format(username, e))
+            try:
+                otp_secret = None
+                if request.form.get('is_hotp', False):
+                    otp_secret = request.form.get('otp_secret', '')
+                new_user = Journalist(username=username,
+                                      password=password,
+                                      is_admin=is_admin,
+                                      otp_secret=otp_secret)
+                db_session.add(new_user)
+                db_session.commit()
+            except PasswordError:
+                flash(gettext(
+                      'There was an error with the autogenerated password. '
+                      'User not created. Please try again.'), 'error')
+                form_valid = False
+            except InvalidUsernameException as e:
+                form_valid = False
+                flash('Invalid username: ' + str(e), "error")
+            except IntegrityError as e:
+                db_session.rollback()
+                form_valid = False
+                if "UNIQUE constraint failed: journalists.username" in str(e):
+                    flash(gettext("That username is already in use"),
+                          "error")
+                else:
+                    flash(gettext("An error occurred saving this user"
+                                  " to the database."
+                                  " Please inform your administrator."),
+                          "error")
+                    current_app.logger.error("Adding user "
+                                             "'{}' failed: {}".format(
+                                                 username, e))
 
             if form_valid:
                 return redirect(url_for('admin.new_user_two_factor',
                                         uid=new_user.id))
 
-        return render_template("admin_add_user.html", password=make_password())
+        return render_template("admin_add_user.html", password=make_password(),
+                               form=form)
 
     @view.route('/2fa', methods=('GET', 'POST'))
     @admin_required
diff --git a/securedrop/journalist_app/forms.py b/securedrop/journalist_app/forms.py
--- a/securedrop/journalist_app/forms.py
+++ b/securedrop/journalist_app/forms.py
@@ -2,8 +2,44 @@
 
 from flask_babel import gettext
 from flask_wtf import FlaskForm
-from wtforms import TextAreaField
-from wtforms.validators import InputRequired
+from wtforms import (TextAreaField, TextField, BooleanField, HiddenField,
+                     ValidationError)
+from wtforms.validators import InputRequired, Optional
+
+from db import Journalist
+
+
+def otp_secret_validation(form, field):
+    strip_whitespace = field.data.replace(' ', '')
+    if len(strip_whitespace) != 40:
+        raise ValidationError(gettext('Field must be 40 characters long but '
+                                      'got {num_chars}.'.format(
+                                          num_chars=len(strip_whitespace)
+                                      )))
+
+
+def minimum_length_validation(form, field):
+    if len(field.data) < Journalist.MIN_USERNAME_LEN:
+        raise ValidationError(
+            gettext('Field must be at least {min_chars} '
+                    'characters long but only got '
+                    '{num_chars}.'.format(
+                        min_chars=Journalist.MIN_USERNAME_LEN,
+                        num_chars=len(field.data))))
+
+
+class NewUserForm(FlaskForm):
+    username = TextField('username', validators=[
+        InputRequired(message=gettext('This field is required.')),
+        minimum_length_validation
+    ])
+    password = HiddenField('password')
+    is_admin = BooleanField('is_admin')
+    is_hotp = BooleanField('is_hotp')
+    otp_secret = TextField('otp_secret', validators=[
+        otp_secret_validation,
+        Optional()
+    ])
 
 
 class ReplyForm(FlaskForm):


Test patch:
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -552,7 +552,7 @@ def test_admin_add_user_without_username(self):
                                 data=dict(username='',
                                           password=VALID_PASSWORD,
                                           is_admin=None))
-        self.assertIn('Invalid username', resp.data)
+        self.assertIn('This field is required.', resp.data)
 
     def test_admin_add_user_too_short_username(self):
         self._login_admin()
@@ -562,7 +562,52 @@ def test_admin_add_user_too_short_username(self):
                                           password='pentagonpapers',
                                           password_again='pentagonpapers',
                                           is_admin=None))
-        self.assertIn('Invalid username', resp.data)
+        self.assertIn('Field must be at least {} characters long'.format(
+                          Journalist.MIN_USERNAME_LEN),
+                      resp.data)
+
+    def test_admin_add_user_yubikey_odd_length(self):
+        self._login_admin()
+        resp = self.client.post(url_for('admin.add_user'),
+                                data=dict(username='dellsberg',
+                                          password=VALID_PASSWORD,
+                                          password_again=VALID_PASSWORD,
+                                          is_admin=None,
+                                          is_hotp=True,
+                                          otp_secret='123'))
+        self.assertIn('Field must be 40 characters long', resp.data)
+
+    def test_admin_add_user_yubikey_valid_length(self):
+        self._login_admin()
+
+        otp = '1234567890123456789012345678901234567890'
+        resp = self.client.post(url_for('admin.add_user'),
+                                data=dict(username='dellsberg',
+                                          password=VALID_PASSWORD,
+                                          password_again=VALID_PASSWORD,
+                                          is_admin=None,
+                                          is_hotp=True,
+                                          otp_secret=otp),
+                                follow_redirects=True)
+
+        # Should redirect to the token verification page
+        self.assertIn('Enable YubiKey (OATH-HOTP)', resp.data)
+
+    def test_admin_add_user_yubikey_correct_length_with_whitespace(self):
+        self._login_admin()
+
+        otp = '12 34 56 78 90 12 34 56 78 90 12 34 56 78 90 12 34 56 78 90'
+        resp = self.client.post(url_for('admin.add_user'),
+                                data=dict(username='dellsberg',
+                                          password=VALID_PASSWORD,
+                                          password_again=VALID_PASSWORD,
+                                          is_admin=None,
+                                          is_hotp=True,
+                                          otp_secret=otp),
+                                follow_redirects=True)
+
+        # Should redirect to the token verification page
+        self.assertIn('Enable YubiKey (OATH-HOTP)', resp.data)
 
     def test_admin_sets_user_to_admin(self):
         self._login_admin()