diff --git a/securedrop/sass/source.sass b/securedrop/sass/source.sass index 56a08ab247..788a8f8773 100644 --- a/securedrop/sass/source.sass +++ b/securedrop/sass/source.sass @@ -43,9 +43,6 @@ h1, h2, h3, h4 h1, h2 font-size: 24px -#source-lookup h2 - font-size: 18px - p margin: 0 0 12px 0 @@ -211,7 +208,7 @@ header width: 150px padding-top: 60px - 17px padding-bottom: 30px - margin: 0 30px 0 15px + margin: 0 30px 0 25px #source-index .content @@ -473,18 +470,18 @@ mark background: $passphrase_masked_color clip-path: polygon(0% 0%, 0% 100%, 20% 100%, 20% 0%, 23% 0%, 23% 100%, 32% 100%, 32% 0%, 35% 0%, 35% 100%, 42% 100%, 42% 0%, 45% 0%, 45% 100%, 64% 100%, 64% 0%, 67% 0%, 67% 100%, 74% 100%, 74% 0%, 77% 0%, 77% 100%, 90% 100%, 90% 0%, 93% 0%, 93% 100%, 100% 100%, 100% 0%) -#codename-reminder +#passphrase-reminder font-size: 18px color: $heading_alt_color -#codename-show-checkbox +#passphrase-show-checkbox position: absolute @include ltr left: -999rem @include rtl right: -999rem - &~ #codename-show + &~ #passphrase-show font-size: 11px line-height: 14px margin-top: -2px @@ -501,7 +498,7 @@ mark border-color: $checkbox_hover_border box-shadow: inset 0 0 0 1px $checkbox_hover_border_inside - @mixin codename-show-checkbox + @mixin passphrase-show-checkbox content: "" background: $body_bg display: block @@ -511,10 +508,10 @@ mark &::before @include ltr - @include codename-show-checkbox + @include passphrase-show-checkbox &::after @include rtl - @include codename-show-checkbox + @include passphrase-show-checkbox &::before @include ltr @@ -525,7 +522,7 @@ mark margin-left: 12px float: right - &:focus-visible ~ #codename-show + &:focus-visible ~ #passphrase-show box-shadow: 0 0 0 3px $focus_shadow &:checked ~ mark > span @@ -543,14 +540,14 @@ mark height: 13px border: 0 - &:checked ~ #codename-show::before + &:checked ~ #passphrase-show::before @include ltr @include checked - &:checked ~ #codename-show::after + &:checked ~ #passphrase-show::after @include rtl @include checked -#codename-show +#passphrase-show display: block #upload @@ -944,18 +941,23 @@ header img, #locales-menu, #locales-menu-list main width: calc(100% - 210px) - border: 1px solid $main_border - padding: 50px 30px 24px 30px + padding: 50px 20px 0 15px section[aria-labelledby="submit-heading"] clear: both -section[aria-labelledby="codename-reminder"] + section h2 +section[aria-labelledby="welcome-heading"] + margin-top: 50px + +section[aria-labelledby="passphrase-reminder"] + section h2 padding-top: 26px #source-lookup main, #source-logout main padding-top: 22px +#source-lookup #encrypt-before-sending + display: none + #source-logout main nav + section margin-top: 44px @@ -1138,11 +1140,11 @@ section[aria-labelledby="codename-reminder"] + section h2 .info padding: 9px -#flashed + section, #codename-hint + section:not(#flashed) +#flashed + section, #passphrase-hint + section:not(#flashed) margin-top: 3em #source-lookup nav + section:not(#flashed) - margin-top: 53px + margin-top: 47px nav + section#flashed margin-top: -1em diff --git a/securedrop/source_app/decorators.py b/securedrop/source_app/decorators.py index 5c1cbdbb89..3e879b9071 100644 --- a/securedrop/source_app/decorators.py +++ b/securedrop/source_app/decorators.py @@ -3,28 +3,49 @@ from db import db from flask import redirect, url_for, request, session +import werkzeug from functools import wraps -from typing import Callable +from typing import Callable, Union, Optional from source_app.utils import clear_session_and_redirect_to_logged_out_page from source_app.session_manager import SessionManager, UserNotLoggedIn, \ UserSessionExpired, UserHasBeenDeleted +from source_user import SourceUser -def login_required(f: Callable) -> Callable: +def _source_user() -> Optional[Union[SourceUser, werkzeug.Response]]: + try: + return SessionManager.get_logged_in_user(db_session=db.session) + + except (UserSessionExpired, UserHasBeenDeleted): + return clear_session_and_redirect_to_logged_out_page(flask_session=session) + + except UserNotLoggedIn: + return None + + +def login_possible(f: Callable) -> Callable: @wraps(f) - def decorated_function(*args: Any, **kwargs: Any) -> Any: - try: - logged_in_source = SessionManager.get_logged_in_user(db_session=db.session) + def decorated_function(*args: Any, **kwargs: Any) -> Union[str, werkzeug.Response]: + result = _source_user() + if isinstance(result, werkzeug.Response): + return result + + return f(*args, **kwargs, logged_in_source=result) + return decorated_function - except (UserSessionExpired, UserHasBeenDeleted): - return clear_session_and_redirect_to_logged_out_page(flask_session=session) - except UserNotLoggedIn: +def login_required(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args: Any, **kwargs: Any) -> Union[str, werkzeug.Response]: + result = _source_user() + if result is None: return redirect(url_for("main.login")) + elif isinstance(result, werkzeug.Response): + return result - return f(*args, **kwargs, logged_in_source=logged_in_source) + return f(*args, **kwargs, logged_in_source=result) return decorated_function diff --git a/securedrop/source_app/forms.py b/securedrop/source_app/forms.py index d03dda79ba..52658c983b 100644 --- a/securedrop/source_app/forms.py +++ b/securedrop/source_app/forms.py @@ -11,14 +11,15 @@ class LoginForm(FlaskForm): - codename = PasswordField('codename', validators=[ + passphrase = PasswordField('passphrase', validators=[ InputRequired(message=gettext('This field is required.')), Length(1, PassphraseGenerator.MAX_PASSPHRASE_LENGTH, message=gettext( 'Field must be between 1 and ' - '{max_codename_len} characters long.'.format( - max_codename_len=PassphraseGenerator.MAX_PASSPHRASE_LENGTH))), + '{max_passphrase_len} characters long.'.format( + max_passphrase_len=PassphraseGenerator.MAX_PASSPHRASE_LENGTH))), # Make sure to allow dashes since some words in the wordlist have them + # TODO Regexp(r'[\sA-Za-z0-9-]+$', message=gettext('Invalid input.')) ]) @@ -31,15 +32,12 @@ class SubmissionForm(FlaskForm): def validate_msg(self, field: wtforms.Field) -> None: if len(field.data) > Submission.MAX_MESSAGE_LEN: - message = gettext("Message text too long.") + err = gettext("The message you submit can be at most " + "{} characters long.").format(Submission.MAX_MESSAGE_LEN) if InstanceConfig.get_default().allow_document_uploads: - message = "{} {}".format( - message, - gettext( - "Large blocks of text must be uploaded as a file, not copied and pasted." - ) - ) - raise ValidationError(message) + err = "{} {}".format(err, gettext("If you need to submit large blocks of text, " + "you can upload them as a file.")) + raise ValidationError(err) def validate_antispam(self, field: wtforms.Field) -> None: """If the antispam field has any contents, abort with a 403""" diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py index 298c6fab6c..ccfaf99860 100644 --- a/securedrop/source_app/main.py +++ b/securedrop/source_app/main.py @@ -1,10 +1,9 @@ import operator import os import io +from datetime import datetime, timezone -from base64 import urlsafe_b64encode -from datetime import datetime, timedelta, timezone -from typing import Union +from typing import Optional, Union import werkzeug from flask import (Blueprint, render_template, redirect, url_for, @@ -21,10 +20,9 @@ from models import Submission, Reply, get_one_or_else, InstanceConfig from passphrases import PassphraseGenerator, DicewarePassphrase from sdconfig import SDConfig -from source_app.decorators import login_required +from source_app.decorators import login_required, login_possible from source_app.session_manager import SessionManager -from source_app.utils import normalize_timestamps, fit_codenames_into_cookie, \ - clear_session_and_redirect_to_logged_out_page, codename_detected, flash_msg +from source_app.utils import normalize_timestamps, passphrase_detected, flash_msg from source_app.forms import LoginForm, SubmissionForm from source_user import InvalidPassphraseError, create_source_user, \ SourcePassphraseCollisionError, SourceDesignationCollisionError, SourceUser @@ -34,11 +32,15 @@ def make_blueprint(config: SDConfig) -> Blueprint: view = Blueprint('main', __name__) @view.route('/') - def index() -> str: + def index() -> Union[str, werkzeug.Response]: + # Behave like a webmail application + if SessionManager.is_user_logged_in(db_session=db.session): + return redirect(url_for('.lookup')) return render_template('index.html') - @view.route('/generate', methods=('POST', 'GET')) - def generate() -> Union[str, werkzeug.Response]: + @view.route('/lookup', methods=('POST', 'GET')) + @login_possible + def lookup(logged_in_source: 'Optional[SourceUser]') -> Union[str, werkzeug.Response]: if request.method == 'POST': # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled tor2web_check = request.form.get('tor2web_check') @@ -48,137 +50,75 @@ def generate() -> Union[str, werkzeug.Response]: elif tor2web_check != 'href="fake.onion"': return redirect(url_for('info.tor2web_warning')) - if SessionManager.is_user_logged_in(db_session=db.session): - flash_msg("notification", None, gettext( - "You were redirected because you are already logged in. " - "If you want to create a new account, you should log out first.")) - return redirect(url_for('.lookup')) - codename = PassphraseGenerator.get_default().generate_passphrase( - preferred_language=g.localeinfo.language - ) - - # Generate a unique id for each browser tab and associate the codename with this id. - # This will allow retrieval of the codename displayed in the tab from which the source has - # clicked to proceed to /generate (ref. issue #4458) - tab_id = urlsafe_b64encode(os.urandom(64)).decode() - codenames = session.get('codenames', {}) - codenames[tab_id] = codename - session['codenames'] = fit_codenames_into_cookie(codenames) - session["codenames_expire"] = datetime.now(timezone.utc) + timedelta( - minutes=config.SESSION_EXPIRATION_MINUTES - ) - return render_template('generate.html', codename=codename, tab_id=tab_id) - - @view.route('/create', methods=['POST']) - def create() -> werkzeug.Response: - if SessionManager.is_user_logged_in(db_session=db.session): - flash_msg("notification", None, gettext( - "You are already logged in. Please verify your codename as it " - "may differ from the one displayed on the previous page.")) - else: - # Ensure the codenames have not expired - date_codenames_expire = session.get("codenames_expire") - if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire: - return clear_session_and_redirect_to_logged_out_page(flask_session=session) - - tab_id = request.form['tab_id'] - codename = session['codenames'][tab_id] - del session['codenames'] - - try: - current_app.logger.info("Creating new source user...") - create_source_user( - db_session=db.session, - source_passphrase=codename, - source_app_storage=Storage.get_default(), - ) - except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e: - current_app.logger.error("Could not create a source: {}".format(e)) - flash_msg("error", None, gettext( - "There was a temporary problem creating your account. Please try again.")) - return redirect(url_for('.index')) - - # All done - source user was successfully created - current_app.logger.info("New source user created") - session['new_user_codename'] = codename - SessionManager.log_user_in(db_session=db.session, - supplied_passphrase=DicewarePassphrase(codename)) - - return redirect(url_for('.lookup')) - - @view.route('/lookup', methods=('GET',)) - @login_required - def lookup(logged_in_source: SourceUser) -> str: replies = [] - logged_in_source_in_db = logged_in_source.get_db_record() - source_inbox = Reply.query.filter_by( - source_id=logged_in_source_in_db.id, deleted_by_source=False - ).all() - - first_submission = logged_in_source_in_db.interaction_count == 0 - - if first_submission: - min_message_length = InstanceConfig.get_default().initial_message_min_len - else: - min_message_length = 0 - - for reply in source_inbox: - reply_path = Storage.get_default().path( - logged_in_source.filesystem_id, - reply.filename, - ) - try: - with io.open(reply_path, "rb") as f: - contents = f.read() - decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( - for_source_user=logged_in_source, - ciphertext_in=contents + if logged_in_source is not None: + logged_in_source_in_db = logged_in_source.get_db_record() + source_inbox = Reply.query.filter_by( + source_id=logged_in_source_in_db.id, deleted_by_source=False + ).all() + + for reply in source_inbox: + reply_path = Storage.get_default().path( + logged_in_source.filesystem_id, + reply.filename, ) - reply.decrypted = decrypted_reply - except UnicodeDecodeError: - current_app.logger.error("Could not decode reply %s" % - reply.filename) - except FileNotFoundError: - current_app.logger.error("Reply file missing: %s" % - reply.filename) - else: - reply.date = datetime.utcfromtimestamp( - os.stat(reply_path).st_mtime) - replies.append(reply) - - # Sort the replies by date - replies.sort(key=operator.attrgetter('date'), reverse=True) + try: + with io.open(reply_path, "rb") as f: + contents = f.read() + decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( + for_source_user=logged_in_source, + ciphertext_in=contents + ) + reply.decrypted = decrypted_reply + except UnicodeDecodeError: + current_app.logger.error("Could not decode reply %s" % + reply.filename) + except FileNotFoundError: + current_app.logger.error("Reply file missing: %s" % + reply.filename) + else: + reply.date = datetime.utcfromtimestamp( + os.stat(reply_path).st_mtime) + replies.append(reply) + + # Sort the replies by date + replies.sort(key=operator.attrgetter('date'), reverse=True) + + # If not done yet, generate a keypair to encrypt replies from the journalist + encryption_mgr = EncryptionManager.get_default() + try: + encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) + except GpgKeyNotFoundError: + encryption_mgr.generate_source_key_pair(logged_in_source) - # If not done yet, generate a keypair to encrypt replies from the journalist - encryption_mgr = EncryptionManager.get_default() - try: - encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) - except GpgKeyNotFoundError: - encryption_mgr.generate_source_key_pair(logged_in_source) + # If the source is logged in, they already submitted at least once + min_message_length = 0 + new_user_passphrase = session.get('new_user_passphrase', None) + else: + min_message_length = InstanceConfig.get_default().initial_message_min_len + new_user_passphrase = None return render_template( 'lookup.html', - is_user_logged_in=True, + is_user_logged_in=logged_in_source is not None, allow_document_uploads=InstanceConfig.get_default().allow_document_uploads, replies=replies, min_len=min_message_length, - new_user_codename=session.get('new_user_codename', None), + new_user_passphrase=new_user_passphrase, form=SubmissionForm(), ) @view.route('/submit', methods=('POST',)) - @login_required - def submit(logged_in_source: SourceUser) -> werkzeug.Response: - allow_document_uploads = InstanceConfig.get_default().allow_document_uploads - form = SubmissionForm() - if not form.validate(): - for field, errors in form.errors.items(): - for error in errors: - flash_msg("error", None, error) - return redirect(url_for('main.lookup')) + @login_possible + def submit(logged_in_source: 'Optional[SourceUser]') -> werkzeug.Response: + # Flow inversion: generate passphrase create source user before processing their + # submission, rather than on a separate screen + # Handle as much validation as possible upfront, as we only want to generate sources if + # necessary msg = request.form['msg'] fh = None + allow_document_uploads = InstanceConfig.get_default().allow_document_uploads if allow_document_uploads and 'fh' in request.files: fh = request.files['fh'] @@ -192,34 +132,69 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: flash_msg("error", None, html_contents) return redirect(url_for('main.lookup')) - fnames = [] - logged_in_source_in_db = logged_in_source.get_db_record() - first_submission = logged_in_source_in_db.interaction_count == 0 + form = SubmissionForm() + if not form.validate(): + for field, errors in form.errors.items(): + for error in errors: + flash_msg("error", None, error) + return redirect(url_for('main.lookup')) - if first_submission: + if logged_in_source is None: min_len = InstanceConfig.get_default().initial_message_min_len if (min_len > 0) and (msg and not fh) and (len(msg) < min_len): flash_msg("error", None, gettext( "Your first message must be at least {} characters long.").format(min_len)) return redirect(url_for('main.lookup')) - # if the new_user_codename key is not present in the session, this is - # not a first session - new_codename = session.get('new_user_codename', None) + passphrase = PassphraseGenerator.get_default().generate_passphrase( + preferred_language=g.localeinfo.language + ) + try: + current_app.logger.info("Creating new source user...") + create_source_user( + db_session=db.session, + source_passphrase=passphrase, + source_app_storage=Storage.get_default(), + ) + except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e: + current_app.logger.error("Could not create a source: {}".format(e)) + flash_msg("error", None, gettext( + "There was a temporary problem creating your account. Please try again.")) + return redirect(url_for('.index')) - codenames_rejected = InstanceConfig.get_default().reject_message_with_codename - if new_codename is not None: - if codenames_rejected and codename_detected(msg, new_codename): - flash_msg("error", None, gettext("Please do not submit your codename!"), - gettext("Keep your codename secret, and use it to log in later to " - "check for replies.")) - return redirect(url_for('main.lookup')) + # All done - source user was successfully created + current_app.logger.info("New source user created") + # Track that this user was generated during this session + session['new_user_passphrase'] = passphrase + logged_in_source = SessionManager.log_user_in( + db_session=db.session, + supplied_passphrase=DicewarePassphrase(passphrase) + ) + + # if the new_user_passphrase key is not present in the session, this is + # not a first session - during the first session, the passphrase is displayed + # throughout but we want to discourage sources from sharing it + new_passphrase = session.get('new_user_passphrase', None) + passphrases_rejected = InstanceConfig.get_default().reject_message_with_codename + + if new_passphrase is not None \ + and passphrases_rejected \ + and passphrase_detected(msg, new_passphrase): + flash_msg("error", None, gettext("Please do not submit your passphrase!"), + gettext("Keep your passphrase secret, and use it to log in later to " + "check for replies.")) + return redirect(url_for('main.lookup')) + + logged_in_source_in_db = logged_in_source.get_db_record() if not os.path.exists(Storage.get_default().path(logged_in_source.filesystem_id)): current_app.logger.debug("Store directory not found for source '{}', creating one." .format(logged_in_source_in_db.journalist_designation)) os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id)) + fnames = [] + first_submission = logged_in_source_in_db.interaction_count == 0 + if msg: logged_in_source_in_db.interaction_count += 1 fnames.append( @@ -252,6 +227,7 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: flash_msg("success", gettext("Success!"), html_contents) new_submissions = [] + for fname in fnames: submission = Submission(logged_in_source_in_db, fname, Storage.get_default()) db.session.add(submission) @@ -312,11 +288,11 @@ def login() -> Union[str, werkzeug.Response]: try: SessionManager.log_user_in( db_session=db.session, - supplied_passphrase=DicewarePassphrase(request.form['codename'].strip()) + supplied_passphrase=DicewarePassphrase(request.form['passphrase'].strip()) ) except InvalidPassphraseError: - current_app.logger.info("Login failed for invalid codename") - flash_msg("error", None, gettext("Sorry, that is not a recognized codename.")) + current_app.logger.info("Login failed for invalid passphrase") + flash_msg("error", None, gettext("Sorry, that is not a recognized passphrase.")) else: # Success: a valid passphrase was supplied return redirect(url_for('.lookup', from_login='1')) diff --git a/securedrop/source_app/session_manager.py b/securedrop/source_app/session_manager.py index 7459a58ab6..812649c292 100644 --- a/securedrop/source_app/session_manager.py +++ b/securedrop/source_app/session_manager.py @@ -30,7 +30,7 @@ class SessionManager: """Helper to manage the user's session cookie accessible via flask.session.""" # The keys in flask.session for the user's passphrase and expiration date - _SESSION_COOKIE_KEY_FOR_CODENAME = "codename" + _SESSION_COOKIE_KEY_FOR_CODENAME = "passphrase" _SESSION_COOKIE_KEY_FOR_EXPIRATION_DATE = "expires" @classmethod diff --git a/securedrop/source_app/utils.py b/securedrop/source_app/utils.py index 3c5cf45ac3..f4cd0cf2af 100644 --- a/securedrop/source_app/utils.py +++ b/securedrop/source_app/utils.py @@ -1,36 +1,27 @@ -import json import subprocess +import re +from hmac import compare_digest + +from typing import Optional import werkzeug -from flask import flash -from flask import redirect -from flask import render_template -from flask import current_app -from flask import url_for +from flask import flash, redirect, render_template, current_app, url_for from flask.sessions import SessionMixin from markupsafe import Markup, escape -from store import Storage -from hmac import compare_digest from flask_babel import gettext - -import typing - -import re +from store import Storage from source_user import SourceUser -if typing.TYPE_CHECKING: - from typing import Optional - -def codename_detected(message: str, codename: str) -> bool: +def passphrase_detected(message: str, passphrase: str) -> bool: """ - Check for codenames in incoming messages. including case where user copy/pasted - from /generate or the codename widget on the same page + Check for passphrases in incoming messages. including case where user copy/pasted + from the passphrase widget on the same page """ message = message.strip() - return compare_digest(message.strip(), codename) + return compare_digest(message.strip(), passphrase) def flash_msg( @@ -110,23 +101,3 @@ def check_url_file(path: str, regexp: str) -> 'Optional[str]': def get_sourcev3_url() -> 'Optional[str]': return check_url_file("/var/lib/securedrop/source_v3_url", r"^[a-z0-9]{56}\.onion$") - - -def fit_codenames_into_cookie(codenames: dict) -> dict: - """ - If `codenames` will approach `werkzeug.Response.max_cookie_size` once - serialized, incrementally pop off the oldest codename until the remaining - (newer) ones will fit. - """ - - serialized = json.dumps(codenames).encode() - if len(codenames) > 1 and len(serialized) > 4000: # werkzeug.Response.max_cookie_size = 4093 - if current_app: - current_app.logger.warn(f"Popping oldest of {len(codenames)} " - f"codenames ({len(serialized)} bytes) to " - f"fit within maximum cookie size") - del codenames[list(codenames)[0]] # FIFO - - return fit_codenames_into_cookie(codenames) - - return codenames diff --git a/securedrop/source_templates/error.html b/securedrop/source_templates/error.html index 082832161e..0075847757 100644 --- a/securedrop/source_templates/error.html +++ b/securedrop/source_templates/error.html @@ -4,5 +4,5 @@

{{ gettext('Server error') }}

{{ gettext('Sorry, the website encountered an error and was unable to complete your request.') }}

-

{{ gettext('Look up a codename...') }}

+

{{ gettext('Look up a passphrase...') }}

{% endblock %} diff --git a/securedrop/source_templates/footer.html b/securedrop/source_templates/footer.html index a91cfe7ea9..fb850068ad 100644 --- a/securedrop/source_templates/footer.html +++ b/securedrop/source_templates/footer.html @@ -3,9 +3,9 @@ {{ gettext('Powered by') }} SecureDrop {{ version }}.

- \ No newline at end of file + diff --git a/securedrop/source_templates/generate.html b/securedrop/source_templates/generate.html deleted file mode 100644 index 7f2e381fd7..0000000000 --- a/securedrop/source_templates/generate.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} -{% import 'utils.html' as utils %} - -{% block body %} -

{{ gettext('Get Your Codename') }}

- -

- {{ gettext('A codename in SecureDrop functions as both your username and your password.') }} -

-

- {{ gettext('You will need this codename to log into our SecureDrop later:') }} -

- -{{ utils.codename(codename) }} - - - -
- - - -
-{% endblock %} diff --git a/securedrop/source_templates/index.html b/securedrop/source_templates/index.html index 75c00f1009..60b400c7a8 100644 --- a/securedrop/source_templates/index.html +++ b/securedrop/source_templates/index.html @@ -58,7 +58,7 @@

{{ gettext('{} logo').format(g.organization_nam
       <section id=

{{ gettext('First submission') }}

{{ gettext('First time submitting to our SecureDrop? Start here.') }}

-
+ - -
+ +{% endif %}
-

{{ gettext('Read Replies') }}

+

{{ gettext('Replies') }}

{% if replies %}

- {{ gettext("You have received a reply. To protect your identity in the unlikely event someone learns your codename, please delete all replies when you're done with them. This also lets us know that you are aware of our reply. You can respond by submitting new files and messages above.") }} + {{ gettext("You have received a reply. To protect your identity in the unlikely event someone learns your passphrase, please delete all replies when you're done with them. This also lets us know that you are aware of our reply. You can respond by submitting new files and messages above.") }}

{% for reply in replies %} {%- set timestamp = utils.relative_time(reply.date) -%} @@ -120,5 +88,56 @@

{{ gettext('Are you finished with the replies?') }}< {% endif %}

+{% endif %} +
+

{{ gettext('New Message') }}

+ +

+ {% if allow_document_uploads %} + {{ gettext('If you are already familiar with GPG, you can optionally encrypt your files and messages with our public key before submission. Files are encrypted as they are received by SecureDrop.').format(url=url_for('info.download_public_key')) }} + {% else %} + {{ gettext('If you are already familiar with GPG, you can optionally encrypt your messages with our public key before submission.').format(url=url_for('info.download_public_key')) }} + {% endif %} + {{ gettext('Learn more.').format(url=url_for('info.why_download_public_key')) }} +

+ +
+ +
+
+ {% if min_len > 0 %} + {{ form.msg(**{"aria-describedby": "min-char-length"}) }} + {% if allow_document_uploads %} +

{{ gettext('If you are only sending a message, it must be at least {} characters long.').format(min_len) }}

+ {% else %} +

{{ gettext('Your first message must be at least {} characters long.').format(min_len) }}

+ {% endif %} + {% else %} + {{ form.msg() }} + {% endif %} +
+ {% if allow_document_uploads %} +
+ {{ form.fh(**{"aria-describedby": "max-file-size"}) }} +

{{ gettext('Maximum upload size: 500 MB') }}

+
+ {% endif %} +
+ + {{ form.antispam() }} +
+
+ +
+ + {{ gettext('CANCEL') }} + + +
+
+
{% endblock %} diff --git a/securedrop/source_templates/notfound.html b/securedrop/source_templates/notfound.html index 9abd23cb4f..1e2966e13d 100644 --- a/securedrop/source_templates/notfound.html +++ b/securedrop/source_templates/notfound.html @@ -4,5 +4,5 @@

{{ gettext('Page not found') }}

{{ gettext("Sorry, we couldn't locate what you requested.") }}

-

{{ gettext('Look up a codename...') }}

+

{{ gettext('Look up a passphrase...') }}

{% endblock %} diff --git a/securedrop/source_templates/utils.html b/securedrop/source_templates/utils.html index 5976828592..96a40151df 100644 --- a/securedrop/source_templates/utils.html +++ b/securedrop/source_templates/utils.html @@ -3,25 +3,20 @@ datetime="{{ date|html_datetime_format }}">{{ date|rel_datetime_format(relative=True) }} {%- endmacro -%} -{%- macro codename(codename, new=False) -%} -{% if new %} -
-

{{ gettext('Remember, your codename is:') }}

-{% else %} -
- -{% endif %} +{%- macro passphrase(passphrase, new=True) -%} +
+ - + - {{ codename }} + {{ passphrase }} - -
+ {%- endmacro -%} diff --git a/securedrop/tests/functional/app_navigators.py b/securedrop/tests/functional/app_navigators.py index b6e96a032a..f06c8dbd4c 100644 --- a/securedrop/tests/functional/app_navigators.py +++ b/securedrop/tests/functional/app_navigators.py @@ -195,25 +195,24 @@ def source_visits_source_homepage(self) -> None: self.driver.get(self._source_app_base_url) assert self._is_on_source_homepage() - def _is_on_generate_page(self) -> WebElement: - return self.nav_helper.wait_for(lambda: self.driver.find_element_by_id("source-generate")) + def _is_on_lookup_page(self) -> WebElement: + return self.nav_helper.wait_for(lambda: self.driver.find_element_by_id("source-lookup")) def source_clicks_submit_documents_on_homepage(self) -> None: # It's the source's first time visiting this SecureDrop site, so they # choose to "Submit Documents". self.nav_helper.safe_click_by_css_selector("#started-form 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() + # The source should now be on the lookup page where they can submit + # docs/messages. They will get a passphrase after their first submission. + assert self._is_on_lookup_page() def source_continues_to_submit_page(self) -> None: - self.nav_helper.safe_click_by_css_selector("#create-form button") def submit_page_loaded() -> None: if not self.accept_languages: - headline = self.driver.find_element_by_id("submit-heading") - assert "Submit Files or Messages" == headline.text + headline = self.driver.find_element_by_id("welcome-heading") + assert "Welcome!" == headline.text self.nav_helper.wait_for(submit_page_loaded) @@ -228,14 +227,14 @@ def source_logs_out(self) -> None: def source_retrieves_codename_from_hint(self) -> str: # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked - # the codename is hidden - content = self.driver.find_element_by_id("codename-show-checkbox") - assert content.get_attribute("checked") is None + content = self.driver.find_element_by_id("passphrase-show-checkbox") - self.nav_helper.safe_click_by_id("codename-show") + # TODO: should the codename be hidden by default under inverted flow? + # assert content.get_attribute("checked") is None + # self.nav_helper.safe_click_by_id("codename-show") assert content.get_attribute("checked") is not None - content_content = self.driver.find_element_by_css_selector("#codename span") + content_content = self.driver.find_element_by_css_selector("#passphrase span") return content_content.text def source_chooses_to_login(self) -> None: @@ -248,7 +247,7 @@ def _is_logged_in(self) -> WebElement: return self.nav_helper.wait_for(lambda: self.driver.find_element_by_id("logout")) def source_proceeds_to_login(self, codename: str) -> None: - self.nav_helper.safe_send_keys_by_id("codename", codename) + self.nav_helper.safe_send_keys_by_id("passphrase", codename) self.nav_helper.safe_click_by_css_selector(".form-controls button") # Check that we've logged in @@ -257,7 +256,11 @@ def source_proceeds_to_login(self, codename: str) -> None: replies = self.driver.find_elements_by_id("replies") assert len(replies) == 1 - def source_submits_a_message(self, message: str = "S3cr3t m3ss4ge") -> str: + def source_submits_a_message( + self, + message: str = "S3cr3t m3ss4ge", + will_succeed: bool = True + ) -> str: # Write the message to submit self.nav_helper.safe_send_keys_by_css_selector("[name=msg]", message) @@ -271,9 +274,21 @@ def message_submitted(): assert "Thank" in notification.text return notification.text + # If it's expected that the submission will fail, wait for an error + # notificaition + def message_failed(): + if not self.accept_languages: + notification = self.driver.find_element_by_css_selector(".error") + assert len(notification.text) > 0 + return notification.text + # Return the confirmation notification - notification_text = self.nav_helper.wait_for(message_submitted) - return notification_text + if will_succeed: + notification_text = self.nav_helper.wait_for(message_submitted) + return notification_text + else: + notification_text = self.nav_helper.wait_for(message_failed) + return notification_text def source_sees_flash_message(self) -> str: notification = self.driver.find_element_by_css_selector(".notification") diff --git a/securedrop/tests/functional/pageslayout/test_journalist_col.py b/securedrop/tests/functional/pageslayout/test_journalist_col.py index 14d966a9ba..72a198ce55 100644 --- a/securedrop/tests/functional/pageslayout/test_journalist_col.py +++ b/securedrop/tests/functional/pageslayout/test_journalist_col.py @@ -32,7 +32,7 @@ def test_col_no_documents(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -45,7 +45,7 @@ def test_col_has_no_key(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._source_delete_key() @@ -57,7 +57,7 @@ def test_col(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -68,7 +68,7 @@ def test_col_javascript(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() diff --git a/securedrop/tests/functional/pageslayout/test_source.py b/securedrop/tests/functional/pageslayout/test_source.py index 6e56dfe70a..8abcd1124c 100644 --- a/securedrop/tests/functional/pageslayout/test_source.py +++ b/securedrop/tests/functional/pageslayout/test_source.py @@ -39,6 +39,7 @@ def test_lookup_shows_codename(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() + self._source_submits_a_message(first_submission=True) self._source_shows_codename() self._screenshot('source-lookup-shows-codename.png') self._save_html('source-lookup-shows-codename.html') @@ -83,7 +84,7 @@ def test_source_checks_for_reply(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_checks_messages() diff --git a/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py b/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py index 1f28ee03f6..c5b856166c 100644 --- a/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py +++ b/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py @@ -38,12 +38,13 @@ def test_index_and_logout(self, locale, sd_servers_v2): # And they have disabled JS in their browser disable_js(navigator.driver) - # When they first login, it succeeds + # When they first submit a file, it succeeds and creates their account navigator.source_visits_source_homepage() save_screenshot_and_html(navigator.driver, locale, "source-index") navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message() # And when they logout, it succeeds navigator.source_logs_out() diff --git a/securedrop/tests/functional/pageslayout/test_source_session_layout.py b/securedrop/tests/functional/pageslayout/test_source_session_layout.py index 2360be899c..b9987576b9 100644 --- a/securedrop/tests/functional/pageslayout/test_source_session_layout.py +++ b/securedrop/tests/functional/pageslayout/test_source_session_layout.py @@ -20,7 +20,7 @@ def _sd_servers_with_short_timeout(setup_journalist_key_and_gpg_folder): """Spawn the source and journalist apps as separate processes with a short session timeout.""" # Generate a securedrop config with a very short session timeout config_with_short_timeout = SecureDropConfigFactory.create( - SESSION_EXPIRATION_MINUTES=SESSION_EXPIRATION_SECONDS / 60, + SESSION_EXPIRATION_MINUTES=SESSION_EXPIRATION_SECONDS / 60.0, SECUREDROP_DATA_ROOT=Path("/tmp/sd-tests/functional-session-timeout"), ) @@ -49,6 +49,7 @@ def test_source_session_timeout(self, locale, _sd_servers_with_short_timeout): navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message() # And their session just expired time.sleep(SESSION_EXPIRATION_SECONDS + 1) diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index 50d2ea04c9..33c5ecb4c2 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -15,9 +15,6 @@ def _is_logged_in(self): def _is_on_lookup_page(self): return self.wait_for(lambda: self.driver.find_element_by_id("source-lookup")) - def _is_on_generate_page(self): - return self.wait_for(lambda: self.driver.find_element_by_id("source-generate")) - def _is_on_logout_page(self): return self.wait_for(lambda: self.driver.find_element_by_id("source-logout")) @@ -35,9 +32,9 @@ def _source_clicks_submit_documents_on_homepage(self, assert_success=True): self.safe_click_by_css_selector("#started-form button") 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() + # The source should now be on the lookup page - codename is not + # yet visible + assert self._is_on_lookup_page() def _source_regenerates_codename(self): self._source_visits_source_homepage() @@ -47,41 +44,33 @@ def _source_regenerates_codename(self): def _source_chooses_to_submit_documents(self): self._source_clicks_submit_documents_on_homepage() - - codename = self.driver.find_element_by_css_selector("#codename span") - - assert len(codename.text) > 0 - self.source_name = codename.text + submit_header = self.driver.find_element_by_css_selector("#welcome-heading") + assert len(submit_header.text) > 0 def _source_shows_codename(self, verify_source_name=True): # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked # the codename is hidden - content = self.driver.find_element_by_id("codename-show-checkbox") - assert content.get_attribute("checked") is None - - # In the UI, the label is actually the element that is being clicked, altering the state - # of the input - self.safe_click_by_id("codename-show") - + content = self.driver.find_element_by_id("passphrase-show-checkbox") assert content.get_attribute("checked") is not None - content_content = self.driver.find_element_by_css_selector("#codename span") + + content_content = self.driver.find_element_by_css_selector("#passphrase span") if verify_source_name: assert content_content.text == self.source_name def _source_hides_codename(self): # We use inputs to change CSS states for subsequent elements in the DOM, if it is checked # the codename is visible - content = self.driver.find_element_by_id("codename-show-checkbox") + content = self.driver.find_element_by_id("passphrase-show-checkbox") assert content.get_attribute("checked") is not None # In the UI, the label is actually the element that is being clicked, altering the state # of the input - self.safe_click_by_id("codename-show") + self.safe_click_by_id("passphrase-show") assert content.get_attribute("checked") is None def _source_sees_no_codename(self): - codename = self.driver.find_elements_by_css_selector("#codename span") + codename = self.driver.find_elements_by_css_selector("#passphrase span") assert len(codename) == 0 def _source_chooses_to_login(self): @@ -97,7 +86,7 @@ def _source_hits_cancel_at_login_page(self): assert self._is_on_source_homepage() def _source_proceeds_to_login(self): - self.safe_send_keys_by_id("codename", self.source_name) + self.safe_send_keys_by_id("passphrase", self.source_name) self.safe_click_by_css_selector(".form-controls button") # Check that we've logged in @@ -108,30 +97,32 @@ def _source_proceeds_to_login(self): def _source_enters_codename_in_login_form(self): self.safe_send_keys_by_id( - "codename", "ascension hypertext concert synopses" + "passphrase", "ascension hypertext concert synopses" ) def _source_hits_cancel_at_submit_page(self): self.safe_click_by_css_selector(".form-controls a") if not self.accept_languages: - heading = self.driver.find_element_by_id("submit-heading") - assert "Submit Files or Messages" == heading.text + heading = self.driver.find_element_by_id("welcome-heading") + assert "Welcome!" == heading.text def _source_continues_to_submit_page(self, files_allowed=True): - self.safe_click_by_css_selector("#create-form button") - def submit_page_loaded(): + def uploader_is_visible(): + try: + self.driver.find_element_by_class_name("attachment") + except NoSuchElementException: + return False + return True + if not self.accept_languages: - heading = self.driver.find_element_by_id("submit-heading") if files_allowed: - assert "Submit Files or Messages" == heading.text + assert uploader_is_visible() else: - assert "Submit Messages" == heading.text - - self.wait_for(submit_page_loaded) + assert not uploader_is_visible() - def _source_submits_a_file(self): + def _source_submits_a_file(self, first_submission=False): with tempfile.NamedTemporaryFile() as file: file.write(self.secret_message.encode("utf-8")) file.seek(0) @@ -141,19 +132,22 @@ def _source_submits_a_file(self): self.safe_send_keys_by_id("fh", filename) self.safe_click_by_css_selector(".form-controls button") - self.wait_for_source_key(self.source_name) - def file_submitted(): + def file_submitted(first_submission=False): if not self.accept_languages: notification = self.driver.find_element_by_class_name("success") - expected_notification = "Thank you for sending this information to us" + if first_submission: + expected_notification = "Please check back later for replies." + else: + expected_notification = "Success!\nThanks! We received your document." assert expected_notification in notification.text # Allow extra time for file uploads - self.wait_for(file_submitted, timeout=(self.timeout * 3)) + self.wait_for(lambda: file_submitted(first_submission), timeout=(self.timeout * 3)) - # allow time for reply key to be generated - time.sleep(self.timeout) + if first_submission: + codename = self.driver.find_element_by_css_selector("#passphrase span") + self.source_name = codename.text def _source_submits_a_message( self, verify_notification=False, first_submission=False, first_login=False @@ -161,7 +155,8 @@ def _source_submits_a_message( self._source_enters_text_in_message_field() self.safe_click_by_css_selector(".form-controls button") - def message_submitted(): + def message_submitted(first_submission=False, verify_notification=False): + if not self.accept_languages: notification = self.driver.find_element_by_class_name("success") assert "Thank" in notification.text @@ -176,8 +171,15 @@ def message_submitted(): else: assert not first_submission_text - self.wait_for(message_submitted) + self.wait_for(lambda: message_submitted( + verify_notification=verify_notification, + first_submission=first_submission + )) + # passphrase is only available on submission in first session + if first_submission: + codename = self.driver.find_element_by_css_selector("#passphrase span") + self.source_name = codename.text # allow time for reply key to be generated time.sleep(self.timeout) diff --git a/securedrop/tests/functional/test_journalist.py b/securedrop/tests/functional/test_journalist.py index a0d320e12d..fe0fb37943 100644 --- a/securedrop/tests/functional/test_journalist.py +++ b/securedrop/tests/functional/test_journalist.py @@ -37,8 +37,7 @@ def test_journalist_verifies_deletion_of_one_submission_modal(self): # This deletion button is displayed on the individual source page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -48,8 +47,7 @@ def test_journalist_uses_col_delete_collection_button_modal(self): # This delete button is displayed on the individual source page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -59,8 +57,7 @@ def test_journalist_uses_index_delete_collections_button_modal(self): # This deletion button is displayed on the index page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_uses_delete_collections_button_confirmation() @@ -69,23 +66,20 @@ def test_journalist_uses_index_delete_files_button_modal(self): # This deletion button is displayed on the index page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_submits_a_message() self._source_logs_out() self._journalist_logs_in() self._journalist_uses_index_delete_files_button_confirmation() self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_message() self._source_logs_out() def test_journalist_interface_ui_with_modal(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self.set_tbb_securitylevel(SECURITY_LOW) @@ -113,8 +107,7 @@ def missing_msg_file(self): # Submit a message self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_message() + self._source_submits_a_message(first_submission=True) self._source_logs_out() # Remove the message file from the store diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py index f27686019b..984804795e 100644 --- a/securedrop/tests/functional/test_source.py +++ b/securedrop/tests/functional/test_source.py @@ -1,8 +1,6 @@ import requests -import werkzeug from tests.functional.app_navigators import SourceAppNagivator -from ..test_journalist import VALID_PASSWORD from tests.functional import tor_utils @@ -17,19 +15,20 @@ def test_no_codename_hint_on_second_login(self, sd_servers_v2, tor_browser_web_d ) # Given a source user who creates an account - # When they first login + # When they first submit a message navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message("they're here") # Then they are able to retrieve their codename from the UI source_codename = navigator.source_retrieves_codename_from_hint() assert source_codename # And they are able to close the codename hint UI - content = navigator.driver.find_element_by_id("codename-show-checkbox") + content = navigator.driver.find_element_by_id("passphrase-show-checkbox") assert content.get_attribute("checked") is not None - navigator.nav_helper.safe_click_by_id("codename-show") + navigator.nav_helper.safe_click_by_id("passphrase-show") assert content.get_attribute("checked") is None # And on their second login @@ -39,7 +38,7 @@ def test_no_codename_hint_on_second_login(self, sd_servers_v2, tor_browser_web_d navigator.source_proceeds_to_login(codename=source_codename) # The codename hint UI is no longer present - codename = navigator.driver.find_elements_by_css_selector("#codename-reminder") + codename = navigator.driver.find_elements_by_css_selector("#passphrase-reminder") assert len(codename) == 0 def test_submission_notifications_on_first_login(self, sd_servers_v2, tor_browser_web_driver): @@ -72,10 +71,16 @@ def test_submission_notifications_on_second_login(self, sd_servers_v2, tor_brows web_driver=tor_browser_web_driver, ) - # Given a source user who creates an account + # Given a source user who creates an account by submitting a + # message on first login navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + confirmation_text_first_submission = navigator.source_submits_a_message() + + # And they see the expected confirmation messages for a first submission on second login + assert self.FIRST_SUBMISSION_TEXT in confirmation_text_first_submission + source_codename = navigator.source_retrieves_codename_from_hint() assert source_codename @@ -85,12 +90,6 @@ def test_submission_notifications_on_second_login(self, sd_servers_v2, tor_brows navigator.source_chooses_to_login() navigator.source_proceeds_to_login(codename=source_codename) - # Then it succeeds - confirmation_text_first_submission = navigator.source_submits_a_message() - - # And they see the expected confirmation messages for a first submission on second login - assert self.FIRST_SUBMISSION_TEXT in confirmation_text_first_submission - # And when they submit a second message confirmation_text_second_submission = navigator.source_submits_a_message() @@ -127,11 +126,11 @@ def test_generate_codenames_in_multiple_tabs(self, sd_servers_v2, tor_browser_we web_driver=tor_browser_web_driver, ) - # Given a user who generated a codename in Tab A + # Given a user who opens /lookup in tab A tab_a = navigator.driver.window_handles[0] navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() - codename_a = self._extract_generated_codename(navigator) + navigator.source_continues_to_submit_page() # And they then opened a new tab, Tab B navigator.driver.execute_script("window.open('about:blank', '_blank')") @@ -139,98 +138,25 @@ def test_generate_codenames_in_multiple_tabs(self, sd_servers_v2, tor_browser_we navigator.driver.switch_to.window(tab_b) assert tab_a != tab_b - # And they also generated another codename in Tab B + # And they also opened /lookup in Tab B navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() - codename_b = self._extract_generated_codename(navigator) - assert codename_a != codename_b + navigator.source_continues_to_submit_page() # And they ended up creating their account and submitting documents in Tab A navigator.driver.switch_to.window(tab_a) navigator.source_continues_to_submit_page() self._assert_is_on_lookup_page(navigator) - assert navigator.source_retrieves_codename_from_hint() == codename_a navigator.source_submits_a_message() + passphrase_a = navigator.source_retrieves_codename_from_hint() # When the user tries to create an account and submit documents in Tab B navigator.driver.switch_to.window(tab_b) - navigator.source_continues_to_submit_page() - - # Then the submission fails and the user sees the corresponding flash message in Tab B - self._assert_is_on_lookup_page(navigator) - notification = navigator.source_sees_flash_message() - if not navigator.accept_languages: - assert "You are already logged in." in notification.text - - # And the user's actual codename is the one initially generated in Tab A - assert navigator.source_retrieves_codename_from_hint() == codename_a - - def test_generate_and_refresh_codenames_in_multiple_tabs( - self, sd_servers_v2, tor_browser_web_driver - ): - navigator = SourceAppNagivator( - source_app_base_url=sd_servers_v2.source_app_base_url, - web_driver=tor_browser_web_driver, - ) - - # Given a user who generated a codename in Tab A - tab_a = navigator.driver.window_handles[0] - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - codename_a1 = self._extract_generated_codename(navigator) - - # And they then re-generated their codename in Tab - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - codename_a2 = self._extract_generated_codename(navigator) - assert codename_a1 != codename_a2 - - # And they then opened a new tab, Tab B - navigator.driver.execute_script("window.open('about:blank', '_blank')") - tab_b = navigator.driver.window_handles[1] - navigator.driver.switch_to.window(tab_b) - assert tab_a != tab_b - - # And they also generated another codename in Tab B - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - codename_b = self._extract_generated_codename(navigator) - assert codename_a2 != codename_b - - # And they ended up creating their account and submitting documents in Tab A - navigator.driver.switch_to.window(tab_a) - navigator.source_continues_to_submit_page() - self._assert_is_on_lookup_page(navigator) - assert navigator.source_retrieves_codename_from_hint() == codename_a2 navigator.source_submits_a_message() + passphrase_b = navigator.source_retrieves_codename_from_hint() - # When they try to re-generate a codename in Tab B - navigator.driver.switch_to.window(tab_b) - navigator.source_visits_source_homepage() - navigator.nav_helper.safe_click_by_css_selector("#started-form button") - - # Then they get redirected to /lookup with the corresponding flash message + # Then the submission succeeds self._assert_is_on_lookup_page(navigator) - notification = navigator.source_sees_flash_message() - if not navigator.accept_languages: - assert "You were redirected because you are already logged in." in notification.text - - # And the user's actual codename is the expected one - assert navigator.source_retrieves_codename_from_hint() == codename_a2 - - # TODO(AD): This test takes ~50s ; we could refactor it to speed it up - def test_codenames_exceed_max_cookie_size(self, sd_servers_v2, tor_browser_web_driver): - """Test generation of enough codenames that the resulting cookie exceeds the recommended - `werkzeug.Response.max_cookie_size` = 4093 bytes. (#6043) - """ - navigator = SourceAppNagivator( - source_app_base_url=sd_servers_v2.source_app_base_url, - web_driver=tor_browser_web_driver, - ) - too_many = 2 * (werkzeug.Response.max_cookie_size // len(VALID_PASSWORD)) - for _ in range(too_many): - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - - navigator.source_continues_to_submit_page() + # And the user's actual codename is the one initially generated in Tab A + assert passphrase_b == passphrase_a diff --git a/securedrop/tests/functional/test_source_designation_collision.py b/securedrop/tests/functional/test_source_designation_collision.py index 444f28a3b2..1ac84f0f73 100644 --- a/securedrop/tests/functional/test_source_designation_collision.py +++ b/securedrop/tests/functional/test_source_designation_collision.py @@ -46,14 +46,16 @@ def test(self, _sd_servers_with_designation_collisions, tor_browser_web_driver): navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message() navigator.source_logs_out() # When another source user creates an account but gets the same journalist designation navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() + navigator.source_continues_to_submit_page() + navigator.source_submits_a_message(will_succeed=False) # Then the right error message is displayed - navigator.nav_helper.safe_click_by_css_selector("#create-form button") navigator.nav_helper.wait_for( lambda: navigator.driver.find_element_by_css_selector(".error") ) diff --git a/securedrop/tests/functional/test_submit_and_retrieve_file.py b/securedrop/tests/functional/test_submit_and_retrieve_file.py index 4bd252aabb..993db7608e 100644 --- a/securedrop/tests/functional/test_submit_and_retrieve_file.py +++ b/securedrop/tests/functional/test_submit_and_retrieve_file.py @@ -12,7 +12,7 @@ def test_submit_and_retrieve_happy_path(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() try: self.switch_to_firefox_driver() diff --git a/securedrop/tests/test_integration.py b/securedrop/tests/test_integration.py index ac3645bc46..5d6d5349ee 100644 --- a/securedrop/tests/test_integration.py +++ b/securedrop/tests/test_integration.py @@ -45,17 +45,13 @@ 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.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) - filesystem_id = source_user.filesystem_id - - # redirected to submission form + # on first submission, a user account should be created resp = app.post('/submit', data=dict( msg=test_msg, fh=(BytesIO(b''), ''), ), follow_redirects=True) + source_user = SessionManager.get_logged_in_user(db_session=db.session) + filesystem_id = source_user.filesystem_id assert resp.status_code == 200 app.get('/logout') @@ -134,18 +130,14 @@ def test_submit_file(journalist_app, source_app, test_journo, app_storage): test_filename = "test.txt" with source_app.test_client() as app: - 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) - filesystem_id = source_user.filesystem_id - - # redirected to submission form + # account is created after first submission resp = app.post('/submit', data=dict( msg="", fh=(BytesIO(test_file_contents), test_filename), ), follow_redirects=True) assert resp.status_code == 200 + source_user = SessionManager.get_logged_in_user(db_session=db.session) + filesystem_id = source_user.filesystem_id app.get('/logout') with journalist_app.test_client() as app: @@ -226,15 +218,13 @@ 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.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 + # user created after first submission resp = app.post('/submit', data=dict( msg=test_msg, fh=(BytesIO(b''), ''), ), follow_redirects=True) assert resp.status_code == 200 + passphrase = session.get("new_user_passphrase") source_user = SessionManager.get_logged_in_user(db_session=db.session) filesystem_id = source_user.filesystem_id app.get('/logout') @@ -298,7 +288,7 @@ def _helper_test_reply(journalist_app, source_app, config, test_journo, _helper_filenames_delete(app, soup, last_reply_number) with source_app.test_client() as app: - resp = app.post('/login', data=dict(codename=codename), + resp = app.post('/login', data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.get('/lookup') @@ -406,9 +396,6 @@ def test_delete_collection(mocker, source_app, journalist_app, test_journo): # first, add a source with source_app.test_client() as app: - 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( msg="This is a test.", fh=(BytesIO(b''), ''), @@ -456,9 +443,6 @@ 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.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( msg="This is a test " + str(i) + ".", fh=(BytesIO(b''), ''), @@ -494,6 +478,7 @@ def assertion(): def _helper_filenames_submit(app): + # if source account does not exist yet, this submission will create it app.post('/submit', data=dict( msg="This is a test.", fh=(BytesIO(b''), ''), @@ -513,9 +498,6 @@ 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.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}) _helper_filenames_submit(app) # navigate to the collection page @@ -540,9 +522,6 @@ 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.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}) _helper_filenames_submit(app) # navigate to the collection page @@ -653,14 +632,12 @@ 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.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - resp = app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) + resp = app.post('/lookup', data=GENERATE_DATA) assert resp.status_code == 200 text = resp.data.decode('utf-8') soup = BeautifulSoup(text, 'html.parser') - assert 'Submit Messages' in text + assert 'Welcome' in text assert len(soup.select('input[type="file"]')) == 0 @@ -683,12 +660,10 @@ 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.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - resp = app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) + resp = app.post('/lookup', data=GENERATE_DATA) assert resp.status_code == 200 text = resp.data.decode('utf-8') soup = BeautifulSoup(text, 'html.parser') - assert 'Submit Files or Messages' in text + assert 'Welcome' in text assert len(soup.select('input[type="file"]')) == 1 diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py index e0511f5c4d..1a3608399c 100644 --- a/securedrop/tests/test_source.py +++ b/securedrop/tests/test_source.py @@ -30,6 +30,8 @@ from .utils.db_helper import new_codename, submit from .utils.i18n import get_test_locales, language_tag, page_language, xfail_untranslated_messages from .utils.instrument import InstrumentedApp +from wtforms.validators import ValidationError + GENERATE_DATA = {'tor2web_check': 'href="fake.onion"'} @@ -106,126 +108,110 @@ def _find_codename(html): return codename_match.group('codename') -def test_generate_already_logged_in(source_app): +def test_index_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.post(url_for('main.generate'), data=GENERATE_DATA) + resp = app.get(url_for('main.index')) assert resp.status_code == 302 # Make sure it flashes the message on the lookup page - resp = app.post(url_for('main.generate'), data=GENERATE_DATA, follow_redirects=True) + resp = app.get(url_for('main.index'), follow_redirects=True) # Should redirect to /lookup assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "because you are already logged in." in text + assert "source-lookup" in text -def test_create_new_source(source_app): +def test_lookup_post_and_new_source(source_app): with source_app.test_client() as app: - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # source is not created when /lookup is first hit + resp = app.post(url_for('main.lookup'), 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) - assert SessionManager.is_user_logged_in(db_session=db.session) - # should be redirected to /lookup + assert not SessionManager.is_user_logged_in(db_session=db.session) text = resp.data.decode('utf-8') - assert "Submit Files" in text - assert 'codenames' not in session + assert "Welcome" in text + assert 'new_user_passphrase' not in session - -def test_generate_as_post(source_app): - with source_app.test_client() as app: - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # source is created on first_submission + resp = app.post(url_for('main.submit'), data={'msg': 'TEST MESSAGE"'}, follow_redirects=True) + text = resp.data.decode('utf-8') + assert "Keep In Touch" in text assert resp.status_code == 200 - session_codename = next(iter(session['codenames'].values())) - - text = resp.data.decode('utf-8') - assert "functions as both your username and your password" in text + assert SessionManager.is_user_logged_in(db_session=db.session) + assert 'new_user_passphrase' in session - codename = _find_codename(resp.data.decode('utf-8')) - # codename is also stored in the session - make sure it matches the - # codename displayed to the source - assert codename == escape(session_codename) -def test_generate_as_get(source_app): +def test_lookup_no_tor2web_check(source_app): with source_app.test_client() as app: - resp = app.get(url_for('main.generate')) - assert resp.status_code == 200 - session_codename = next(iter(session['codenames'].values())) - - text = resp.data.decode('utf-8') - assert "functions as both your username and your password" in text - - codename = _find_codename(resp.data.decode('utf-8')) - # codename is also stored in the session - make sure it matches the - # codename displayed to the source - assert codename == escape(session_codename) + resp = app.post(url_for('main.lookup'), follow_redirects=True) + assert resp.status_code == 403 +def test_lookup_invalid_tor2web_check(source_app): + with source_app.test_client() as app: + resp = app.post(url_for('main.lookup'), data={'tor2web_check': 'href="fake.onion.ly"'}, follow_redirects=True) + assert resp.status_code == 403 -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.post(url_for('main.generate'), data=GENERATE_DATA) - assert resp.status_code == 200 - tab_id, codename = next(iter(session['codenames'].items())) +def test_lookup_as_get(source_app): + with source_app.test_client() as app: + resp = app.get(url_for('main.lookup')) + assert resp.status_code == 200 + text = resp.data.decode('utf-8') + assert "Welcome" in text + assert 'new_user_passphrase' not in session - # Create a source the first time - resp = app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True) - assert resp.status_code == 200 - with source_app.test_client() as app: - # Attempt to add the same source - with app.session_transaction() as sess: - sess['codenames'] = {tab_id: codename} - sess["codenames_expire"] = datetime.utcnow() + timedelta(hours=1) - resp = app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True) - logger.assert_called_once() - assert "Could not create a source" in logger.call_args[0][0] - assert resp.status_code == 200 - assert not SessionManager.is_user_logged_in(db_session=db.session) +def test_passphrase_is_created_once_only(source_app): + with source_app.test_client() as app: + # Given a user who opened the /lookup page + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) + assert resp.status_code == 200 + text = resp.data.decode('utf-8') + assert "Welcome" in text + assert 'new_user_passphrase' not in session + # And then they visited the homepage without submitting + resp = app.get(url_for('main.index'), follow_redirects=True) -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.post(url_for('main.generate'), data=GENERATE_DATA) + # they should be on the homepage, not redirected. assert resp.status_code == 200 - first_tab_id, first_codename = list(session['codenames'].items())[0] + text = resp.data.decode('utf-8') + assert "first-submission-heading" in text - # And then they opened a new browser tab to generate a second codename - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # then when they visit /lookup again and submit a message + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) assert resp.status_code == 200 - second_tab_id, second_codename = list(session['codenames'].items())[1] - assert first_codename != second_codename + text = resp.data.decode('utf-8') + assert "Welcome" in text + resp = app.post(url_for('main.submit'), data={'msg': 'TEST MESSAGE HEY'}, follow_redirects=True) - # And the user then completed the account creation flow in the first tab - resp = app.post( - url_for('main.create'), data={'tab_id': first_tab_id}, follow_redirects=True - ) + # they should see the post-submission lookup page + text = resp.data.decode('utf-8') + assert "Keep In Touch" in text assert resp.status_code == 200 - first_tab_account = SessionManager.get_logged_in_user(db_session=db.session) + assert SessionManager.is_user_logged_in(db_session=db.session) + assert 'new_user_passphrase' in session - # When the user tries to complete the account creation flow again, in the second tab - resp = app.post( - url_for('main.create'), data={'tab_id': second_tab_id}, follow_redirects=True - ) + # and they should see the passphrase available + passphrase = session.get('new_user_passphrase') + assert len(passphrase) > 0 + assert passphrase in text - # Then the user is shown the "already logged in" message + # And then when they visit the homepage again, they should be redirected + # to /lookup, with the passphrase still visible + resp = app.get(url_for('main.index'), follow_redirects=False) + assert resp.status_code == 302 + resp = app.get(url_for('main.index'), follow_redirects=True) assert resp.status_code == 200 - text = resp.data.decode('utf-8') - assert "You are already logged in." in text - - # And no new account was created - second_tab_account = SessionManager.get_logged_in_user(db_session=db.session) - assert second_tab_account.filesystem_id == first_tab_account.filesystem_id + assert "source-lookup" in text + assert passphrase in text def test_lookup(source_app): """Test various elements on the /lookup page.""" with source_app.test_client() as app: - codename = new_codename(app, session) - resp = app.post(url_for('main.login'), data=dict(codename=codename), + passphrase = new_codename(app, session) + resp = app.post(url_for('main.login'), data=dict(passphrase=passphrase), follow_redirects=True) # redirects to /lookup text = resp.data.decode('utf-8') @@ -251,35 +237,35 @@ def test_login_and_logout(source_app): resp = app.get(url_for('main.login')) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Enter Codename" in text + assert "Enter Passphrase" in text - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "source-lookup" in text assert SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename='invalid'), + data=dict(passphrase='invalid'), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert 'Sorry, that is not a recognized codename.' in text + assert 'Sorry, that is not a recognized passphrase.' in text assert not SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) @@ -294,46 +280,37 @@ def test_login_and_logout(source_app): assert 'This will clear your Tor Browser activity data' in text -def test_user_must_log_in_for_protected_views(source_app): - with source_app.test_client() as app: - resp = app.get(url_for('main.lookup'), - follow_redirects=True) - assert resp.status_code == 200 - text = resp.data.decode('utf-8') - assert "Enter Codename" in text - - def test_login_with_whitespace(source_app): """ Test that codenames with leading or trailing whitespace still work """ - def login_test(app, codename): + def login_test(app, passphrase): resp = app.get(url_for('main.login')) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Enter Codename" in text + assert "Enter Passphrase" in text resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "source-lookup" in text assert SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: - codename = new_codename(app, session) + passphrase = new_codename(app, session) - codenames = [ - codename + ' ', - ' ' + codename + ' ', - ' ' + codename, + passphrases = [ + passphrase + ' ', + ' ' + passphrase + ' ', + ' ' + passphrase, ] - for codename_ in codenames: + for p in passphrases: with source_app.test_client() as app: - login_test(app, codename_) + login_test(app, p) def test_login_with_missing_reply_files(source_app, app_storage): @@ -341,7 +318,7 @@ def test_login_with_missing_reply_files(source_app, app_storage): Test that source can log in when replies are present in database but missing from storage. """ - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) journalist, _ = utils.db_helper.init_journalist() replies = utils.db_helper.reply(app_storage, journalist, source, 1) assert len(replies) > 0 @@ -354,14 +331,14 @@ def test_login_with_missing_reply_files(source_app, app_storage): resp = app.get(url_for('main.login')) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Enter Codename" in text + assert "Enter Passphrase" in text resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "New Message" in text assert SessionManager.is_user_logged_in(db_session=db.session) @@ -381,15 +358,16 @@ def _dummy_submission(app): def test_initial_submission_notification(source_app): """ Regardless of the type of submission (message, file, or both), the - first submission is always greeted with a notification - reminding sources to check back later for replies. + first submission is always greeted with a notification prompting + the source to check back for replies - subsequent messages don't + include the same prompt """ with source_app.test_client() as app: new_codename(app, session) resp = _dummy_submission(app) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Thank you for sending this information to us." in text + assert "Please check back later" in text def test_submit_message(source_app): @@ -431,7 +409,7 @@ def test_submit_big_message(source_app): follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Message text too long." in text + assert "The message you submit can be" in text def test_submit_initial_short_message(source_app): @@ -441,7 +419,6 @@ def test_submit_initial_short_message(source_app): with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=10, reject_codenames=False) - new_codename(app, session) resp = app.post( url_for('main.submit'), data=dict(msg="A" * 5, fh=(StringIO(''), '')), @@ -510,21 +487,21 @@ def test_submit_antispam(source_app): assert resp.status_code == 403 -def test_submit_codename_second_login(source_app): +def test_submit_passphrase_second_login(source_app): """ Test codename submissions *not* prevented on second session """ with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=0, reject_codenames=True) - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Please do not submit your codename!" in text + assert "Please do not submit your passphrase!" in text resp = app.get(url_for('main.logout'), follow_redirects=True) @@ -533,57 +510,57 @@ def test_submit_codename_second_login(source_app): assert 'This will clear your Tor Browser activity data' in text resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Thank you for sending this information" in text + assert "Thanks! We received your message." in text -def test_submit_codename(source_app): +def test_submit_passphrase(source_app): """ Test preventions against people submitting their codename. """ with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=0, reject_codenames=True) - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Please do not submit your codename!" in text + assert "Please do not submit your passphrase!" in text # Do a dummy submission _dummy_submission(app) - # Now resubmit the codename, should be accepted. + # Now resubmit the passphrase, should be rejected for the entire session resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Thanks! We received your message" in text + assert "Please do not submit your passphrase!" in text def test_delete_all_successfully_deletes_replies(source_app, app_storage): with source_app.app_context(): journalist, _ = utils.db_helper.init_journalist() - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) source_id = source.id utils.db_helper.reply(app_storage, journalist, source, 1) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.post(url_for('main.batch_delete'), follow_redirects=True) @@ -603,7 +580,7 @@ def test_delete_all_replies_deleted_by_source_but_not_journalist(source_app, app replies may still exist in the replies table, but no longer be visible.""" with source_app.app_context(): journalist, _ = utils.db_helper.init_journalist() - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) utils.db_helper.reply(app_storage, journalist, source, 1) replies = Reply.query.filter(Reply.source_id == source.id).all() for reply in replies: @@ -614,7 +591,7 @@ def test_delete_all_replies_deleted_by_source_but_not_journalist(source_app, app with source_app.test_client() as app: with patch.object(source_app.logger, 'error') as logger: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.post(url_for('main.batch_delete'), @@ -628,13 +605,13 @@ def test_delete_all_replies_deleted_by_source_but_not_journalist(source_app, app def test_delete_all_replies_already_deleted_by_journalists(source_app, app_storage): with source_app.app_context(): journalist, _ = utils.db_helper.init_journalist() - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) # Note that we are creating the source and no replies with source_app.test_client() as app: with patch.object(source_app.logger, 'error') as logger: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.post(url_for('main.batch_delete'), @@ -666,7 +643,7 @@ def test_submit_sanitizes_filename(source_app): mtime=0) -@pytest.mark.parametrize("test_url", ['main.index', 'main.create', 'main.submit']) +@pytest.mark.parametrize("test_url", ['main.index', 'main.submit']) def test_redirect_when_tor2web(config, source_app, test_url): with source_app.test_client() as app: resp = app.get( @@ -728,13 +705,13 @@ def test_metadata_v3_url(source_app): assert resp.json.get('v3_source_url') == onion_test_url -def test_login_with_overly_long_codename(source_app): +def test_login_with_overly_long_passphrase(source_app): """Attempting to login with an overly long codename should result in an error to avoid DoS.""" - overly_long_codename = 'a' * (PassphraseGenerator.MAX_PASSPHRASE_LENGTH + 1) + overly_long_passphrase = 'a' * (PassphraseGenerator.MAX_PASSPHRASE_LENGTH + 1) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=overly_long_codename), + data=dict(passphrase=overly_long_passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') @@ -752,7 +729,7 @@ def test_normalize_timestamps(source_app, app_storage): """ with source_app.test_client() as app: # create a source - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) # create one submission first_submission = submit(app_storage, source, 1)[0] @@ -766,11 +743,11 @@ def test_normalize_timestamps(source_app, app_storage): # log in as the source resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "source-lookup" in text assert SessionManager.is_user_logged_in(db_session=db.session) # submit another message @@ -814,10 +791,10 @@ def test_failed_normalize_timestamps_logs_warning(source_app): OSSEC alert).""" with patch.object(source_app.logger, 'warning') as logger: - with patch.object(subprocess, 'call', return_value=1): - with source_app.test_client() as app: - new_codename(app, session) - _dummy_submission(app) + with source_app.test_client() as app: + new_codename(app, session) + _dummy_submission(app) + with patch.object(subprocess, 'call', return_value=1): resp = app.post( url_for('main.submit'), data=dict( @@ -839,8 +816,8 @@ def test_source_is_deleted_while_logged_in(source_app): a NoResultFound will occur. The source should be redirected to the index when this happens, and a warning logged.""" with source_app.test_client() as app: - codename = new_codename(app, session) - app.post('login', data=dict(codename=codename), follow_redirects=True) + passphrase = new_codename(app, session) + app.post('login', data=dict(passphrase=passphrase), follow_redirects=True) # Now that the source is logged in, the journalist deletes the source source_user = SessionManager.get_logged_in_user(db_session=db.session) @@ -855,15 +832,15 @@ def test_source_is_deleted_while_logged_in(source_app): assert not SessionManager.is_user_logged_in(db_session=db.session) -def test_login_with_invalid_codename(source_app): +def test_login_with_invalid_passphrase(source_app): """Logging in with a codename with invalid characters should return an informative message to the user.""" - invalid_codename = '[]' + invalid_passphrase = '[]' with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=invalid_codename), + data=dict(passphrase=invalid_passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') @@ -873,9 +850,9 @@ def test_login_with_invalid_codename(source_app): def test_source_session_expiration(source_app): with source_app.test_client() as app: # Given a source user who logs in - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 @@ -892,25 +869,6 @@ def test_source_session_expiration(source_app): assert 'You were logged out due to inactivity' in text -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.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 - with mock.patch("source_app.main.datetime") as mock_datetime: - six_hours_later = datetime.now(timezone.utc) + timedelta(hours=6) - mock_datetime.now.return_value = six_hours_later - - # When the user tries to complete the create flow - resp = app.post(url_for('main.create'), follow_redirects=True) - - # They get redirected to the index page with the "logged out" message - text = resp.data.decode('utf-8') - assert 'You were logged out due to inactivity' in text - - def test_source_no_session_expiration_message_when_not_logged_in(source_app): with source_app.test_client() as app: # Given an unauthenticated source user @@ -934,10 +892,10 @@ def test_csrf_error_page(source_app): source_app.config['WTF_CSRF_ENABLED'] = True with source_app.test_client() as app: with InstrumentedApp(source_app) as ins: - resp = app.post(url_for('main.create')) + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) ins.assert_redirects(resp, url_for('main.index')) - resp = app.post(url_for('main.create'), follow_redirects=True) + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA, follow_redirects=True) text = resp.data.decode('utf-8') assert 'You were logged out due to inactivity' in text @@ -946,8 +904,8 @@ def test_source_can_only_delete_own_replies(source_app, app_storage): '''This test checks for a bug an authenticated source A could delete replies send to source B by "guessing" the filename. ''' - source0, codename0 = utils.db_helper.init_source(app_storage) - source1, codename1 = utils.db_helper.init_source(app_storage) + source0, phrase0 = utils.db_helper.init_source(app_storage) + source1, phrase1 = utils.db_helper.init_source(app_storage) journalist, _ = utils.db_helper.init_journalist() replies = utils.db_helper.reply(app_storage, journalist, source0, 1) filename = replies[0].filename @@ -955,7 +913,7 @@ def test_source_can_only_delete_own_replies(source_app, app_storage): with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data={'codename': codename1}, + data={'passphrase': phrase1}, follow_redirects=True) assert resp.status_code == 200 assert SessionManager.get_logged_in_user(db_session=db.session).db_record_id == source1.id @@ -971,7 +929,7 @@ def test_source_can_only_delete_own_replies(source_app, app_storage): with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data={'codename': codename0}, + data={'passphrase': phrase0}, follow_redirects=True) assert resp.status_code == 200 assert SessionManager.get_logged_in_user(db_session=db.session).db_record_id == source0.id diff --git a/securedrop/tests/test_source_utils.py b/securedrop/tests/test_source_utils.py index 492ee031a3..1a5b998b00 100644 --- a/securedrop/tests/test_source_utils.py +++ b/securedrop/tests/test_source_utils.py @@ -1,12 +1,7 @@ # -*- coding: utf-8 -*- -import json import os -import pytest -import werkzeug - -from source_app.utils import check_url_file, codename_detected, fit_codenames_into_cookie -from .test_journalist import VALID_PASSWORD +from source_app.utils import check_url_file def test_check_url_file(config): @@ -33,42 +28,3 @@ def write_url_file(path, content): finally: if os.path.exists(url_path): os.unlink(url_path) - - -def test_fit_codenames_into_cookie(config): - # A single codename should never be truncated. - codenames = {'a': VALID_PASSWORD} - assert(fit_codenames_into_cookie(codenames) == codenames) - - # A reasonable number of codenames should never be truncated. - codenames = { - 'a': VALID_PASSWORD, - 'b': VALID_PASSWORD, - 'c': VALID_PASSWORD, - } - assert(fit_codenames_into_cookie(codenames) == codenames) - - # A single gargantuan codename is undefined behavior---but also should not - # be truncated. - codenames = {'a': werkzeug.Response.max_cookie_size*VALID_PASSWORD} - assert(fit_codenames_into_cookie(codenames) == codenames) - - # Too many codenames of the expected length should be truncated. - codenames = {} - too_many = 2*(werkzeug.Response.max_cookie_size // len(VALID_PASSWORD)) - for i in range(too_many): - codenames[i] = VALID_PASSWORD - serialized = json.dumps(codenames).encode() - assert(len(serialized) > werkzeug.Response.max_cookie_size) - serialized = json.dumps(fit_codenames_into_cookie(codenames)).encode() - assert(len(serialized) < werkzeug.Response.max_cookie_size) - - -@pytest.mark.parametrize('message,expected', ( - ('Foo', False), - ('codename', True), - (' codename ', True), - ('foocodenamebar', False), -)) -def test_codename_detected(message, expected): - assert codename_detected(message, 'codename') is expected diff --git a/securedrop/tests/utils/db_helper.py b/securedrop/tests/utils/db_helper.py index 4b7671d884..3fac48796a 100644 --- a/securedrop/tests/utils/db_helper.py +++ b/securedrop/tests/utils/db_helper.py @@ -186,9 +186,9 @@ def submit(storage, source, num_submissions, submission_type="message"): def new_codename(client, session): """Helper function to go through the "generate codename" flow. """ - 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}) + client.post('/lookup', data={'tor2web_check': 'href="fake.onion"'}) + client.post('/submit', data={'msg': 'TEST MESSAGE"'}) + codename = session.get('new_user_passphrase') return codename