From e1e9aa4d564f8bb59226e7a307f78c9c9e88c1a2 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 12 Feb 2021 16:13:38 -0800 Subject: [PATCH 1/2] remove v2 and v2+v3 warning Signed-off-by: Allie Crevier --- securedrop/journalist_app/__init__.py | 12 ------ securedrop/journalist_templates/base.html | 12 ------ securedrop/tests/test_journalist.py | 48 ----------------------- 3 files changed, 72 deletions(-) diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index df097f9e92..88cdd759e4 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -56,12 +56,6 @@ def create_app(config: 'SDConfig') -> Flask: def _url_exists(u: str) -> bool: return path.exists(path.join(config.SECUREDROP_DATA_ROOT, u)) - v2_enabled = _url_exists('source_v2_url') or ((not _url_exists('source_v2_url')) - and (not _url_exists('source_v3_url'))) - v3_enabled = _url_exists('source_v3_url') - - app.config.update(V2_ONION_ENABLED=v2_enabled, V3_ONION_ENABLED=v3_enabled) - # TODO: Attaching a Storage dynamically like this disables all type checking (and # breaks code analysis tools) for code that uses current_app.storage; it should be refactored app.storage = Storage(config.STORE_DIR, @@ -163,12 +157,6 @@ def setup_g() -> 'Optional[Response]': else: g.organization_name = gettext('SecureDrop') - if app.config['V2_ONION_ENABLED'] and not app.config['V3_ONION_ENABLED']: - g.show_v2_onion_eol_warning = True - - if app.config['V2_ONION_ENABLED'] and app.config['V3_ONION_ENABLED']: - g.show_v2_onion_migration_warning = True - if request.path.split('/')[1] == 'api': pass # We use the @token_required decorator for the API endpoints else: # We are not using the API diff --git a/securedrop/journalist_templates/base.html b/securedrop/journalist_templates/base.html index a036b10f9d..2766cedfd1 100644 --- a/securedrop/journalist_templates/base.html +++ b/securedrop/journalist_templates/base.html @@ -19,18 +19,6 @@ {% if g.user %} - {% if g.show_v2_onion_eol_warning %} -
- {{ gettext('Update Required  Set up v3 Onion Services before April 30 to keep your SecureDrop servers online. Please contact your administrator. Learn More') }} -
- {% endif %} - - {% if g.show_v2_onion_migration_warning %} -
- {{ gettext('Update Required  Complete the v3 Onion Services setup before April 30. Please contact your administrator. Learn More') }} -
- {% endif %} -
{{ gettext('Logged on as') }} {{ g.user.username }} | {% if g.user and g.user.is_admin %} diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py index 99e19156f0..9529061fd0 100644 --- a/securedrop/tests/test_journalist.py +++ b/securedrop/tests/test_journalist.py @@ -64,54 +64,6 @@ def _login_user(app, username, password, otp_secret): assert hasattr(g, 'user') # ensure logged in -def test_user_sees_v2_eol_warning_if_only_v2_is_enabled(config, journalist_app, test_journo): - journalist_app.config.update(V2_ONION_ENABLED=True, V3_ONION_ENABLED=False) - with journalist_app.test_client() as app: - _login_user( - app, - test_journo['username'], - test_journo['password'], - test_journo['otp_secret']) - - resp = app.get(url_for('main.index')) - - text = resp.data.decode('utf-8') - assert 'id="v2-onion-eol"' in text, text - assert 'id="v2-complete-migration"' not in text, text - - -def test_user_sees_v2_eol_warning_if_both_v2_and_v3_enabled(config, journalist_app, test_journo): - journalist_app.config.update(V2_ONION_ENABLED=True, V3_ONION_ENABLED=True) - with journalist_app.test_client() as app: - _login_user( - app, - test_journo['username'], - test_journo['password'], - test_journo['otp_secret']) - - resp = app.get(url_for('main.index')) - - text = resp.data.decode('utf-8') - assert 'id="v2-onion-eol"' not in text, text - assert 'id="v2-complete-migration"' in text, text - - -def test_user_does_not_see_v2_eol_warning_if_only_v3_enabled(config, journalist_app, test_journo): - journalist_app.config.update(V2_ONION_ENABLED=False, V3_ONION_ENABLED=True) - with journalist_app.test_client() as app: - _login_user( - app, - test_journo['username'], - test_journo['password'], - test_journo['otp_secret']) - - resp = app.get(url_for('main.index')) - - text = resp.data.decode('utf-8') - assert 'id="v2-onion-eol"' not in text, text - assert 'id="v2-complete-migration"' not in text, text - - def test_user_with_whitespace_in_username_can_login(journalist_app): # Create a user with whitespace at the end of the username with journalist_app.app_context(): From ecfecea2583a0a8836e710a854119ae4cb19b3e1 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 19 Feb 2021 10:19:31 -0800 Subject: [PATCH 2/2] inform user about xenial EOL and disable SI if reached --- .../files/usr.sbin.apache2 | 2 +- securedrop/journalist_app/__init__.py | 9 ++- securedrop/journalist_templates/base.html | 10 +++ securedrop/server_os.py | 25 +++++++ securedrop/source_app/__init__.py | 12 +++ securedrop/source_app/decorators.py | 2 +- securedrop/source_templates/base.html | 8 +- securedrop/tests/test_journalist.py | 59 +++++++++++++++ securedrop/tests/test_source.py | 75 ++++++++++++++++++- 9 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 securedrop/server_os.py diff --git a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 index d9c382e828..1e990e63c8 100644 --- a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 +++ b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 @@ -1,4 +1,3 @@ -# Last Modified: Wed Oct 29 08:16:32 2014 #include /usr/sbin/apache2 { @@ -336,6 +335,7 @@ /var/www/securedrop/template_filters.py r, /var/www/securedrop/translations/ r, /var/www/securedrop/translations/** r, + /var/www/securedrop/server_os.py r, /var/www/securedrop/version.py r, /var/www/securedrop/wordlists/ r, /var/www/securedrop/wordlists/** r, diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index 88cdd759e4..9b903cca59 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -21,6 +21,7 @@ JournalistInterfaceSessionInterface, cleanup_expired_revoked_tokens) from models import InstanceConfig, Journalist +from server_os import is_os_near_eol, is_os_past_eol from store import Storage import typing @@ -53,8 +54,7 @@ def create_app(config: 'SDConfig') -> Flask: app.config['SQLALCHEMY_DATABASE_URI'] = config.DATABASE_URI db.init_app(app) - def _url_exists(u: str) -> bool: - return path.exists(path.join(config.SECUREDROP_DATA_ROOT, u)) + app.config.update(OS_PAST_EOL=is_os_past_eol(), OS_NEAR_EOL=is_os_near_eol()) # TODO: Attaching a Storage dynamically like this disables all type checking (and # breaks code analysis tools) for code that uses current_app.storage; it should be refactored @@ -157,6 +157,11 @@ def setup_g() -> 'Optional[Response]': else: g.organization_name = gettext('SecureDrop') + if app.config["OS_PAST_EOL"]: + g.show_os_past_eol_warning = True + elif app.config["OS_NEAR_EOL"]: + g.show_os_near_eol_warning = True + if request.path.split('/')[1] == 'api': pass # We use the @token_required decorator for the API endpoints else: # We are not using the API diff --git a/securedrop/journalist_templates/base.html b/securedrop/journalist_templates/base.html index 2766cedfd1..a17e6ee906 100644 --- a/securedrop/journalist_templates/base.html +++ b/securedrop/journalist_templates/base.html @@ -19,6 +19,16 @@ {% if g.user %} + {% if g.show_os_past_eol_warning %} +
+ {{ gettext ('Critical Security:  The operating system used by your SecureDrop servers has reached its end-of-life. A manual update is required to re-enable the Source Interface and remain safe. Please contact your administrator. Learn More') }} +
+ {% elif g.show_os_near_eol_warning %} +
+ {{ gettext ('Critical Security:  The operating system used by your SecureDrop servers will reach its end-of-life on April 30, 2021. A manual update is urgently required to remain safe. Please contact your adminstrator. Learn More') }} +
+ {% endif %} +
{{ gettext('Logged on as') }} {{ g.user.username }} | {% if g.user and g.user.is_admin %} diff --git a/securedrop/server_os.py b/securedrop/server_os.py new file mode 100644 index 0000000000..03cc55b19c --- /dev/null +++ b/securedrop/server_os.py @@ -0,0 +1,25 @@ +from datetime import date + +FOCAL_VERSION = "20.04" +XENIAL_EOL_DATE = date(2021, 4, 30) + +with open("/etc/lsb-release", "r") as f: + installed_version = f.readlines()[1].split("=")[1].strip("\n") + + +def is_os_past_eol() -> bool: + """ + Assumption: Any OS that is not Focal is an earlier version of the OS. + """ + if installed_version != FOCAL_VERSION and date.today() > XENIAL_EOL_DATE: + return True + return False + + +def is_os_near_eol() -> bool: + """ + Assumption: Any OS that is not Focal is an earlier version of the OS. + """ + if installed_version != FOCAL_VERSION and date.today() <= XENIAL_EOL_DATE: + return True + return False diff --git a/securedrop/source_app/__init__.py b/securedrop/source_app/__init__.py index 5c738713cd..0edd40b0da 100644 --- a/securedrop/source_app/__init__.py +++ b/securedrop/source_app/__init__.py @@ -25,6 +25,7 @@ from source_app.decorators import ignore_static from source_app.utils import logged_in, was_in_generate_flow from store import Storage +from server_os import is_os_past_eol def create_app(config: SDConfig) -> Flask: @@ -42,6 +43,17 @@ def setup_i18n() -> None: """Store i18n-related values in Flask's special g object""" i18n.set_locale(config) + app.config.update(OS_PAST_EOL=is_os_past_eol()) + + @app.before_request + @ignore_static + def disable_ui() -> Optional[str]: + if app.config["OS_PAST_EOL"]: + session.clear() + g.show_offline_message = True + return render_template("base.html") + return None + # The default CSRF token expiration is 1 hour. Since large uploads can # take longer than an hour over Tor, we increase the valid window to 24h. app.config['WTF_CSRF_TIME_LIMIT'] = 60 * 60 * 24 diff --git a/securedrop/source_app/decorators.py b/securedrop/source_app/decorators.py index 2fae92b3df..d8d9d5817c 100644 --- a/securedrop/source_app/decorators.py +++ b/securedrop/source_app/decorators.py @@ -22,7 +22,7 @@ def ignore_static(f: Callable) -> Callable: a static resource.""" @wraps(f) def decorated_function(*args: Any, **kwargs: Any) -> Any: - if request.path.startswith('/static'): + if request.path.startswith("/static") or request.path == "/org-logo": return # don't execute the decorated function return f(*args, **kwargs) return decorated_function diff --git a/securedrop/source_templates/base.html b/securedrop/source_templates/base.html index 6ffe10743d..690396b25e 100644 --- a/securedrop/source_templates/base.html +++ b/securedrop/source_templates/base.html @@ -28,6 +28,11 @@ {% endblock %}
+ {% if g.show_offline_message %} +

{{ gettext("We're sorry, our SecureDrop is currently offline.") }}

+

{{ gettext("Please try again later. Check our website for more information.") }}

+ {% else %} + {% if 'logged_in' in session %} {{ gettext('LOG OUT') }} {% endif %} @@ -36,8 +41,9 @@
{% endif %} - {% block body %}{% endblock %} + {% endif %} + {% block body %}{% endblock %}
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py index 9529061fd0..b8ccdea0cc 100644 --- a/securedrop/tests/test_journalist.py +++ b/securedrop/tests/test_journalist.py @@ -64,6 +64,65 @@ def _login_user(app, username, password, otp_secret): assert hasattr(g, 'user') # ensure logged in +def test_user_sees_os_warning_if_server_past_eol(config, journalist_app, test_journo): + journalist_app.config.update(OS_PAST_EOL=True, OS_NEAR_EOL=False) + with journalist_app.test_client() as app: + _login_user( + app, test_journo["username"], test_journo["password"], test_journo["otp_secret"] + ) + + resp = app.get(url_for("main.index")) + + text = resp.data.decode("utf-8") + assert 'id="os-past-eol"' in text, text + assert 'id="os-near-eol"' not in text, text + + +def test_user_sees_os_warning_if_server_past_eol_sanity_check(config, journalist_app, test_journo): + """ + Sanity check (both conditions cannot be True but test guard against developer error) + """ + journalist_app.config.update(OS_PAST_EOL=True, OS_NEAR_EOL=True) + with journalist_app.test_client() as app: + _login_user( + app, test_journo["username"], test_journo["password"], test_journo["otp_secret"] + ) + + resp = app.get(url_for("main.index")) + + text = resp.data.decode("utf-8") + assert 'id="os-past-eol"' in text, text + assert 'id="os-near-eol"' not in text, text + + +def test_user_sees_os_warning_if_server_close_to_eol(config, journalist_app, test_journo): + journalist_app.config.update(OS_PAST_EOL=False, OS_NEAR_EOL=True) + with journalist_app.test_client() as app: + _login_user( + app, test_journo["username"], test_journo["password"], test_journo["otp_secret"] + ) + + resp = app.get(url_for("main.index")) + + text = resp.data.decode("utf-8") + assert 'id="os-past-eol"' not in text, text + assert 'id="os-near-eol"' in text, text + + +def test_user_does_not_see_os_warning_if_server_is_current(config, journalist_app, test_journo): + journalist_app.config.update(OS_PAST_EOL=False, OS_NEAR_EOL=False) + with journalist_app.test_client() as app: + _login_user( + app, test_journo["username"], test_journo["password"], test_journo["otp_secret"] + ) + + resp = app.get(url_for("main.index")) + + text = resp.data.decode("utf-8") + assert 'id="os-past-eol"' not in text, text + assert 'id="os-near-eol"' not in text, text + + def test_user_with_whitespace_in_username_can_login(journalist_app): # Create a user with whitespace at the end of the username with journalist_app.app_context(): diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py index c82bfcd849..79e16b24bb 100644 --- a/securedrop/tests/test_source.py +++ b/securedrop/tests/test_source.py @@ -5,7 +5,7 @@ import time import os import shutil - +from datetime import date from io import BytesIO, StringIO from pathlib import Path @@ -18,6 +18,7 @@ from . import utils import version +import server_os from db import db from journalist_app.utils import delete_collection from models import InstanceConfig, Source, Reply @@ -30,6 +31,78 @@ overly_long_codename = 'a' * (PassphraseGenerator.MAX_PASSPHRASE_LENGTH + 1) +def test_source_interface_is_disabled_when_xenial_is_eol(config, source_app): + disabled_endpoints = [ + "main.index", + "main.generate", + "main.login", + "info.download_public_key", + "info.tor2web_warning", + "info.recommend_tor_browser", + "info.why_download_public_key", + ] + static_assets = [ + "css/source.css", + "i/custom_logo.png", + "i/font-awesome/fa-globe-black.png", + "i/favicon.png", + ] + with source_app.test_client() as app: + server_os.installed_version = "16.04" + server_os.XENIAL_EOL_DATE = date(2020, 1, 1) + for endpoint in disabled_endpoints: + resp = app.get(url_for(endpoint)) + assert resp.status_code == 200 + text = resp.data.decode("utf-8") + assert "We're sorry, our SecureDrop is currently offline." in text + # Ensure static assets are properly served + for asset in static_assets: + resp = app.get(url_for("static", filename=asset)) + assert resp.status_code == 200 + text = resp.data.decode("utf-8") + assert "We're sorry, our SecureDrop is currently offline." not in text + + +def test_source_interface_is_not_disabled_before_xenial_eol(config, source_app): + disabled_endpoints = [ + "main.index", + "main.generate", + "main.login", + "info.download_public_key", + "info.tor2web_warning", + "info.recommend_tor_browser", + "info.why_download_public_key", + ] + with source_app.test_client() as app: + server_os.installed_version = "16.04" + server_os.XENIAL_EOL_DATE = date(2200, 1, 1) + for endpoint in disabled_endpoints: + resp = app.get(url_for(endpoint), follow_redirects=True) + assert resp.status_code == 200 + text = resp.data.decode("utf-8") + assert "We're sorry, our SecureDrop is currently offline." not in text + + +def test_source_interface_is_not_disabled_for_focal(config, source_app): + disabled_endpoints = [ + "main.index", + "main.generate", + "main.login", + "info.download_public_key", + "info.tor2web_warning", + "info.recommend_tor_browser", + "info.why_download_public_key", + ] + with source_app.test_client() as app: + server_os.installed_version = "20.04" + server_os.XENIAL_EOL_DATE = date(2020, 1, 1) + for endpoint in disabled_endpoints: + resp = app.get(url_for(endpoint)) + assert resp.status_code == 200 + text = resp.data.decode("utf-8") + assert "We're sorry, our SecureDrop is currently offline." not in text + + def test_logo_default_available(source_app): # if the custom image is available, this test will fail custom_image_location = os.path.join(config.SECUREDROP_ROOT, "static/i/custom_logo.png")