Repository: freedomofpress/securedrop

Issue:
Unexpected characters in source codename should not cause 500 error
## Description

On the source interface, unexpected characters in the source codename login a cause a `CryptoException` and 500 error.

## Steps to Reproduce

1. Go to `/login` on source interface.
2. Enter '[]' in the source codename.
3. Hit "Continue"

## Expected Behavior

A reasonable flow would be to flash a message informing the user of the expected format of the codename or which characters in the form input were invalid. 

## Actual Behavior

The generic 500 error page is shown due to a `CryptoException`:

<img width="1026" alt="screen shot 2017-09-27 at 12 16 36 pm" src="https://user-images.githubusercontent.com/7832803/30933009-1837b54a-a37e-11e7-8b6c-7b7b75429a70.png">

## Comments

The current logic here is overly complicated: `login` calls `source_app.utils.valid_codename` which calls `crypto_util.hash_codename` which calls `crypto_util.clean`. In `crypto_util.clean`, if there are unexpected characters in the codename _then_ a `CryptoException` will be thrown. We could just handle the `CryptoException` and flash a message. However, we should be validating these forms separately from `crypto_util.py` (see #2312) and _not_ throw `CryptoException`s for form validation errors. 


Discussion:

Resolving patch:
diff --git a/securedrop/source_app/forms.py b/securedrop/source_app/forms.py
new file mode 100644
--- /dev/null
+++ b/securedrop/source_app/forms.py
@@ -0,0 +1,19 @@
+from flask_babel import gettext
+from flask_wtf import FlaskForm
+from wtforms import PasswordField
+from wtforms.validators import InputRequired, Regexp, Length
+
+from db import Source
+
+
+class LoginForm(FlaskForm):
+    codename = PasswordField('codename', validators=[
+        InputRequired(message=gettext('This field is required.')),
+        Length(1, Source.MAX_CODENAME_LEN,
+               message=gettext('Field must be between 1 and '
+                               '{max_codename_len} characters long. '.format(
+                                   max_codename_len=Source.MAX_CODENAME_LEN))),
+        # The regex here allows either whitespace (\s) or
+        # alphanumeric characters (\W) except underscore (_)
+        Regexp(r'(\s|[^\W_])+$', message=gettext('Invalid input.'))
+    ])
diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py
--- a/securedrop/source_app/main.py
+++ b/securedrop/source_app/main.py
@@ -16,6 +16,7 @@
 from source_app.utils import (logged_in, generate_unique_codename,
                               async_genkey, normalize_timestamps,
                               valid_codename)
+from source_app.forms import LoginForm
 
 
 def make_blueprint(config):
@@ -196,7 +197,8 @@ def batch_delete():
 
     @view.route('/login', methods=('GET', 'POST'))
     def login():
-        if request.method == 'POST':
+        form = LoginForm()
+        if form.validate_on_submit():
             codename = request.form['codename'].strip()
             if valid_codename(codename):
                 session.update(codename=codename, logged_in=True)
@@ -206,7 +208,7 @@ def login():
                         "Login failed for invalid codename".format(codename))
                 flash(gettext("Sorry, that is not a recognized codename."),
                       "error")
-        return render_template('login.html')
+        return render_template('login.html', form=form)
 
     @view.route('/logout')
     def logout():
diff --git a/securedrop/source_app/utils.py b/securedrop/source_app/utils.py
--- a/securedrop/source_app/utils.py
+++ b/securedrop/source_app/utils.py
@@ -15,12 +15,6 @@ def logged_in():
 
 
 def valid_codename(codename):
-    # Ignore codenames that are too long to avoid DoS
-    if len(codename) > Source.MAX_CODENAME_LEN:
-        current_app.logger.info(
-                "Ignored attempted login because the codename was too long.")
-        return False
-
     try:
         filesystem_id = crypto_util.hash_codename(codename)
     except crypto_util.CryptoException as e:


Test patch:
diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py
--- a/securedrop/tests/test_source.py
+++ b/securedrop/tests/test_source.py
@@ -353,7 +353,8 @@ def test_login_with_overly_long_codename(self, mock_hash_codename):
             resp = c.post('/login', data=dict(codename=overly_long_codename),
                           follow_redirects=True)
             self.assertEqual(resp.status_code, 200)
-            self.assertIn("Sorry, that is not a recognized codename.",
+            self.assertIn("Field must be between 1 and {} "
+                          "characters long.".format(Source.MAX_CODENAME_LEN),
                           resp.data)
             self.assertFalse(mock_hash_codename.called,
                              "Called hash_codename for codename w/ invalid "
@@ -407,5 +408,16 @@ def test_source_is_deleted_while_logged_in(self, logger):
 
         logger.assert_called_once_with(
             "Found no Sources when one was expected: "
-            "No row was found for one()"
-        )
+            "No row was found for one()")
+
+    def test_login_with_invalid_codename(self):
+        """Logging in with a codename with invalid characters should return
+        an informative message to the user."""
+
+        invalid_codename = '[]'
+
+        with self.client as c:
+            resp = c.post('/login', data=dict(codename=invalid_codename),
+                          follow_redirects=True)
+            self.assertEqual(resp.status_code, 200)
+            self.assertIn("Invalid input.", resp.data)