Repository: freedomofpress/securedrop

Issue:
investigate and implement improvements to header-based tor2web detection for current active tor2web proxies



Discussion:

Resolving patch:
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
@@ -37,8 +37,15 @@ def make_blueprint(config: SDConfig) -> Blueprint:
     def index() -> str:
         return render_template('index.html')
 
-    @view.route('/generate', methods=('GET',))
+    @view.route('/generate', methods=('POST',))
     def generate() -> Union[str, werkzeug.Response]:
+        # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled
+        tor2web_check = request.form.get('tor2web_check')
+        if tor2web_check is None:
+            # Missing form field
+            abort(403)
+        elif tor2web_check != 'href="fake.onion"':
+            return redirect(url_for('info.tor2web_warning'))
         if SessionManager.is_user_logged_in(db_session=db.session):
             flash(gettext(
                 "You were redirected because you are already logged in. "
@@ -46,7 +53,6 @@ def generate() -> Union[str, werkzeug.Response]:
                 "first."),
                   "notification")
             return redirect(url_for('.lookup'))
-
         codename = PassphraseGenerator.get_default().generate_passphrase(
             preferred_language=g.localeinfo.language
         )


Test patch:
diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py
--- a/securedrop/tests/functional/source_navigation_steps.py
+++ b/securedrop/tests/functional/source_navigation_steps.py
@@ -36,18 +36,22 @@ def _source_checks_instance_metadata(self):
         assert j["sd_version"] == self.source_app.jinja_env.globals["version"]
         assert j["gpg_fpr"] != ""
 
-    def _source_clicks_submit_documents_on_homepage(self):
+    def _source_clicks_submit_documents_on_homepage(self, assert_success=True):
 
         # It's the source's first time visiting this SecureDrop site, so they
         # choose to "Submit Documents".
         self.safe_click_by_id("submit-documents-button")
 
-        # The source should now be on the page where they are presented with
-        # a diceware codename they can use for subsequent logins
-        assert self._is_on_generate_page()
+        if assert_success:
+            # The source should now be on the page where they are presented with
+            # a diceware codename they can use for subsequent logins
+            assert self._is_on_generate_page()
 
     def _source_regenerates_codename(self):
-        self.driver.refresh()
+        self._source_visits_source_homepage()
+        # We do not want to assert success here since it's possible they got
+        # redirected if already logged in.
+        self._source_clicks_submit_documents_on_homepage(assert_success=False)
 
     def _source_chooses_to_submit_documents(self):
         self._source_clicks_submit_documents_on_homepage()
diff --git a/securedrop/tests/test_i18n.py b/securedrop/tests/test_i18n.py
--- a/securedrop/tests/test_i18n.py
+++ b/securedrop/tests/test_i18n.py
@@ -275,7 +275,7 @@ def test_html_en_lang_correct(journalist_app, config):
     assert re.compile('<html lang="en-US".*>').search(html), html
 
     # check '/generate' too because '/' uses a different template
-    resp = app.get('/generate', follow_redirects=True)
+    resp = app.post('/generate', data={'tor2web_check': 'href="fake.onion"'}, follow_redirects=True)
     html = resp.data.decode('utf-8')
     assert re.compile('<html lang="en-US".*>').search(html), html
 
@@ -299,7 +299,8 @@ def test_html_fr_lang_correct(journalist_app, config):
     assert re.compile('<html lang="fr-FR".*>').search(html), html
 
     # check '/generate' too because '/' uses a different template
-    resp = app.get('/generate?l=fr_FR', follow_redirects=True)
+    resp = app.post('/generate?l=fr_FR', data={'tor2web_check': 'href="fake.onion"'},
+                    follow_redirects=True)
     html = resp.data.decode('utf-8')
     assert re.compile('<html lang="fr-FR".*>').search(html), html
 
@@ -329,9 +330,11 @@ def test_html_attributes(journalist_app, config):
     assert '<html lang="en-US" dir="ltr">' in html
 
     # check '/generate' too because '/' uses a different template
-    resp = app.get('/generate?l=ar', follow_redirects=True)
+    resp = app.post('/generate?l=ar', data={'tor2web_check': 'href="fake.onion"'},
+                    follow_redirects=True)
     html = resp.data.decode('utf-8')
     assert '<html lang="ar" dir="rtl">' in html
-    resp = app.get('/generate?l=en_US', follow_redirects=True)
+    resp = app.post('/generate?l=en_US', data={'tor2web_check': 'href="fake.onion"'},
+                    follow_redirects=True)
     html = resp.data.decode('utf-8')
     assert '<html lang="en-US" dir="ltr">' in html
diff --git a/securedrop/tests/test_integration.py b/securedrop/tests/test_integration.py
--- a/securedrop/tests/test_integration.py
+++ b/securedrop/tests/test_integration.py
@@ -26,6 +26,7 @@
 
 # Seed the RNG for deterministic testing
 random.seed('ಠ_ಠ')
+GENERATE_DATA = {'tor2web_check': 'href="fake.onion"'}
 
 
 def _login_user(app, user_dict):
@@ -44,7 +45,7 @@ def test_submit_message(journalist_app, source_app, test_journo, app_storage):
     test_msg = "This is a test message."
 
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         app.post('/create', data={'tab_id': tab_id}, follow_redirects=True)
         source_user = SessionManager.get_logged_in_user(db_session=db.session)
@@ -148,7 +149,7 @@ def test_submit_file(journalist_app, source_app, test_journo, app_storage):
     test_filename = "test.txt"
 
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         app.post('/create', data={'tab_id': tab_id}, follow_redirects=True)
         source_user = SessionManager.get_logged_in_user(db_session=db.session)
@@ -255,7 +256,7 @@ def _helper_test_reply(journalist_app, source_app, config, test_journo,
     test_msg = "This is a test message."
 
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id, codename = next(iter(session['codenames'].items()))
         app.post('/create', data={'tab_id': tab_id}, follow_redirects=True)
         # redirected to submission form
@@ -446,7 +447,7 @@ def test_delete_collection(mocker, source_app, journalist_app, test_journo):
 
     # first, add a source
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         app.post('/create', data={'tab_id': tab_id})
         resp = app.post('/submit', data=dict(
@@ -496,7 +497,7 @@ def test_delete_collections(mocker, journalist_app, source_app, test_journo):
     with source_app.test_client() as app:
         num_sources = 2
         for i in range(num_sources):
-            app.get('/generate')
+            app.post('/generate', data=GENERATE_DATA)
             tab_id = next(iter(session['codenames'].keys()))
             app.post('/create', data={'tab_id': tab_id})
             app.post('/submit', data=dict(
@@ -553,7 +554,7 @@ def test_filenames(source_app, journalist_app, test_journo):
     and files"""
     # add a source and submit stuff
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         app.post('/create', data={'tab_id': tab_id})
         _helper_filenames_submit(app)
@@ -580,7 +581,7 @@ def test_filenames_delete(journalist_app, source_app, test_journo):
     """Test pretty, sequential filenames when journalist deletes files"""
     # add a source and submit stuff
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         app.post('/create', data={'tab_id': tab_id})
         _helper_filenames_submit(app)
@@ -692,7 +693,7 @@ def test_prevent_document_uploads(source_app, journalist_app, test_admin):
 
     # Check that the source interface accepts only messages:
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         resp = app.post('/create', data={'tab_id': tab_id}, follow_redirects=True)
         assert resp.status_code == 200
@@ -718,7 +719,7 @@ def test_no_prevent_document_uploads(source_app, journalist_app, test_admin):
 
     # Check that the source interface accepts both files and messages:
     with source_app.test_client() as app:
-        app.get('/generate')
+        app.post('/generate', data=GENERATE_DATA)
         tab_id = next(iter(session['codenames'].keys()))
         resp = app.post('/create', data={'tab_id': tab_id}, follow_redirects=True)
         assert resp.status_code == 200
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
@@ -31,6 +31,8 @@
 from .utils.i18n import get_test_locales, language_tag, page_language, xfail_untranslated_messages
 from .utils.instrument import InstrumentedApp
 
+GENERATE_DATA = {'tor2web_check': 'href="fake.onion"'}
+
 
 def test_logo_default_available(config, source_app):
     # if the custom image is available, this test will fail
@@ -108,10 +110,10 @@ def test_generate_already_logged_in(source_app):
     with source_app.test_client() as app:
         new_codename(app, session)
         # Make sure it redirects to /lookup when logged in
-        resp = app.get(url_for('main.generate'))
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
         assert resp.status_code == 302
         # Make sure it flashes the message on the lookup page
-        resp = app.get(url_for('main.generate'), follow_redirects=True)
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA, follow_redirects=True)
         # Should redirect to /lookup
         assert resp.status_code == 200
         text = resp.data.decode('utf-8')
@@ -120,7 +122,7 @@ def test_generate_already_logged_in(source_app):
 
 def test_create_new_source(source_app):
     with source_app.test_client() as app:
-        resp = app.get(url_for('main.generate'))
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
         assert resp.status_code == 200
         tab_id = next(iter(session['codenames'].keys()))
         resp = app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True)
@@ -133,7 +135,7 @@ def test_create_new_source(source_app):
 
 def test_generate(source_app):
     with source_app.test_client() as app:
-        resp = app.get(url_for('main.generate'))
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
         assert resp.status_code == 200
         session_codename = next(iter(session['codenames'].values()))
 
@@ -149,7 +151,7 @@ def test_generate(source_app):
 def test_create_duplicate_codename_logged_in_not_in_session(source_app):
     with patch.object(source_app.logger, 'error') as logger:
         with source_app.test_client() as app:
-            resp = app.get(url_for('main.generate'))
+            resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
             assert resp.status_code == 200
             tab_id, codename = next(iter(session['codenames'].items()))
 
@@ -172,12 +174,12 @@ def test_create_duplicate_codename_logged_in_not_in_session(source_app):
 def test_create_duplicate_codename_logged_in_in_session(source_app):
     with source_app.test_client() as app:
         # Given a user who generated a codename in a browser tab
-        resp = app.get(url_for('main.generate'))
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
         assert resp.status_code == 200
         first_tab_id, first_codename = list(session['codenames'].items())[0]
 
         # And then they opened a new browser tab to generate a second codename
-        resp = app.get(url_for('main.generate'))
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
         assert resp.status_code == 200
         second_tab_id, second_codename = list(session['codenames'].items())[1]
         assert first_codename != second_codename
@@ -780,7 +782,7 @@ def test_source_session_expiration(source_app):
 def test_source_session_expiration_create(source_app):
     with source_app.test_client() as app:
         # Given a source user who is in the middle of the account creation flow
-        resp = app.get(url_for('main.generate'))
+        resp = app.post(url_for('main.generate'), data=GENERATE_DATA)
         assert resp.status_code == 200
 
         # But we're now 6 hours later hence they did not finish the account creation flow in time
diff --git a/securedrop/tests/utils/db_helper.py b/securedrop/tests/utils/db_helper.py
--- a/securedrop/tests/utils/db_helper.py
+++ b/securedrop/tests/utils/db_helper.py
@@ -186,7 +186,7 @@ def submit(storage, source, num_submissions, submission_type="message"):
 def new_codename(client, session):
     """Helper function to go through the "generate codename" flow.
     """
-    client.get('/generate')
+    client.post('/generate', data={'tor2web_check': 'href="fake.onion"'})
     tab_id, codename = next(iter(session['codenames'].items()))
     client.post('/create', data={'tab_id': tab_id})
     return codename