Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide end-of life messaging and disable on source interface after Xenial EOL #5789

Merged
merged 2 commits into from Feb 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -1,4 +1,3 @@
# Last Modified: Wed Oct 29 08:16:32 2014
#include <tunables/global>

/usr/sbin/apache2 {
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 6 additions & 13 deletions securedrop/journalist_app/__init__.py
Expand Up @@ -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
Expand Down Expand Up @@ -53,14 +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))

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)
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
Expand Down Expand Up @@ -163,11 +157,10 @@ 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 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
Expand Down
20 changes: 9 additions & 11 deletions securedrop/journalist_templates/base.html
Expand Up @@ -19,17 +19,15 @@
<body>

{% if g.user %}
{% if g.show_v2_onion_eol_warning %}
<div id="v2-onion-eol" class="alert-banner">
<img src="{{ url_for('static', filename='i/bang-circle.png') }}" width="20" height="20"> {{ gettext('<strong>Update Required</strong>&nbsp;&nbsp;Set up v3 Onion Services before April 30 to keep your SecureDrop servers online. Please contact your administrator. <a href="//securedrop.org/v2-onion-eol" rel="noreferrer">Learn More</a>') }}
</div>
{% endif %}

{% if g.show_v2_onion_migration_warning %}
<div id="v2-complete-migration" class="alert-banner">
<img src="{{ url_for('static', filename='i/bang-circle.png') }}" width="20" height="20"> {{ gettext('<strong>Update Required</strong>&nbsp;&nbsp;Complete the v3 Onion Services setup before April 30. Please contact your administrator. <a href="//securedrop.org/v2-onion-eol" rel="noreferrer">Learn More</a>') }}
</div>
{% endif %}
{% if g.show_os_past_eol_warning %}
<div id="os-past-eol" class="alert-banner">
<img src="{{ url_for('static', filename='i/bang-circle.png') }}" width="20" height="20"> {{ gettext ('<strong>Critical Security:</strong>&nbsp;&nbsp;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. <a href="//securedrop.org/xenial-eol" rel="noreferrer">Learn More</a>') }}
</div>
{% elif g.show_os_near_eol_warning %}
<div id="os-near-eol" class="alert-banner">
<img src="{{ url_for('static', filename='i/bang-circle.png') }}" width="20" height="20"> {{ gettext ('<strong>Critical Security:</strong>&nbsp;&nbsp;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. <a href="//securedrop.org/xenial-eol" rel="noreferrer">Learn More</a>') }}
</div>
{% endif %}

<div id="logout">
{{ gettext('Logged on as') }} <a href="{{ url_for('account.edit') }}" id="link-edit-account">{{ g.user.username }}</a> |
Expand Down
25 changes: 25 additions & 0 deletions 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
12 changes: 12 additions & 0 deletions securedrop/source_app/__init__.py
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion securedrop/source_app/decorators.py
Expand Up @@ -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
8 changes: 7 additions & 1 deletion securedrop/source_templates/base.html
Expand Up @@ -28,6 +28,11 @@
{% endblock %}

<div class="panel selected">
{% if g.show_offline_message %}
<h1>{{ gettext("We're sorry, our SecureDrop is currently offline.") }}</h1>
<p>{{ gettext("Please try again later. Check our website for more information.") }}</p>
{% else %}

{% if 'logged_in' in session %}
<a href="{{ url_for('main.logout') }}" class="btn pull-right" id="logout">{{ gettext('LOG OUT') }}</a>
{% endif %}
Expand All @@ -36,8 +41,9 @@
<hr class="no-line">
{% endif %}

{% block body %}{% endblock %}
{% endif %}

{% block body %}{% endblock %}
</div>
</div>

Expand Down
71 changes: 41 additions & 30 deletions securedrop/tests/test_journalist.py
Expand Up @@ -64,52 +64,63 @@ 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)
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'])
app, test_journo["username"], test_journo["password"], test_journo["otp_secret"]
)

resp = app.get(url_for('main.index'))
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
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_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)
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'])
app, test_journo["username"], test_journo["password"], test_journo["otp_secret"]
)

resp = app.get(url_for('main.index'))
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
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_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)
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'])
app, test_journo["username"], test_journo["password"], test_journo["otp_secret"]
)

resp = app.get(url_for('main.index'))
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
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):
Expand Down
75 changes: 74 additions & 1 deletion securedrop/tests/test_source.py
Expand Up @@ -5,7 +5,7 @@
import time
import os
import shutil

from datetime import date
from io import BytesIO, StringIO
from pathlib import Path

Expand All @@ -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
Expand All @@ -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")
Expand Down