Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add basic message filtering in the SI #6306

Merged
merged 1 commit into from
Mar 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""remove partial_index on valid_until, set it to nullable=false, add message filter fields

Revision ID: 55f26cd22043
Revises: 2e24fc7536e8
Create Date: 2022-03-03 22:40:36.149638

"""
from alembic import op
import sqlalchemy as sa

from datetime import datetime

# revision identifiers, used by Alembic.
revision = '55f26cd22043'
down_revision = '2e24fc7536e8'
branch_labels = None
depends_on = None


def upgrade():
# remove the old partial index on valid_until, as alembic can't handle it.
op.execute(sa.text('DROP INDEX IF EXISTS ix_one_active_instance_config'))

# valid_until will be non-nullable after batch, so set existing nulls to
# the new default unix epoch datetime
op.execute(sa.text(
'UPDATE OR IGNORE instance_config SET '
'valid_until=:epoch WHERE valid_until IS NULL;'
).bindparams(epoch=datetime.fromtimestamp(0)))
with op.batch_alter_table('instance_config', schema=None) as batch_op:
batch_op.alter_column('valid_until',
legoktm marked this conversation as resolved.
Show resolved Hide resolved
existing_type=sa.DATETIME(),
nullable=False)
batch_op.add_column(sa.Column('initial_message_min_len', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('reject_message_with_codename', sa.Boolean(), nullable=True))

# remove the old partial index *again* in case the batch op recreated it.
op.execute(sa.text('DROP INDEX IF EXISTS ix_one_active_instance_config'))


def downgrade():
with op.batch_alter_table('instance_config', schema=None) as batch_op:
batch_op.alter_column('valid_until',
existing_type=sa.DATETIME(),
nullable=True)
batch_op.drop_column('reject_message_with_codename')
batch_op.drop_column('initial_message_min_len')

# valid_until is nullable again, set entries with unix epoch datetime value to NULL
op.execute(sa.text(
'UPDATE OR IGNORE instance_config SET '
'valid_until = NULL WHERE valid_until=:epoch;'
).bindparams(epoch=datetime.fromtimestamp(0)))

# manually restore the partial index
op.execute(sa.text('CREATE UNIQUE INDEX IF NOT EXISTS ix_one_active_instance_config ON '
'instance_config (valid_until IS NULL) WHERE valid_until IS NULL'))
28 changes: 23 additions & 5 deletions securedrop/journalist_app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from db import db
from html import escape
from models import (InstanceConfig, Journalist, InvalidUsernameException,
FirstOrLastNameError, PasswordError)
FirstOrLastNameError, PasswordError, Submission)
from journalist_app.decorators import admin_required
from journalist_app.utils import (commit_account_changes, set_diceware_password,
validate_hotp_secret, revoke_token)
Expand All @@ -36,9 +36,18 @@ def index() -> str:
@view.route('/config', methods=('GET', 'POST'))
@admin_required
def manage_config() -> Union[str, werkzeug.Response]:
# The UI prompt ("prevent") is the opposite of the setting ("allow"):
if InstanceConfig.get_default().initial_message_min_len > 0:
prevent_short_messages = True
legoktm marked this conversation as resolved.
Show resolved Hide resolved
else:
prevent_short_messages = False

# The UI document upload prompt ("prevent") is the opposite of the setting ("allow")
submission_preferences_form = SubmissionPreferencesForm(
prevent_document_uploads=not InstanceConfig.get_default().allow_document_uploads)
prevent_document_uploads=not InstanceConfig.get_default().allow_document_uploads,
prevent_short_messages=prevent_short_messages,
min_message_length=InstanceConfig.get_default().initial_message_min_len,
reject_codename_messages=InstanceConfig.get_default().reject_message_with_codename
)
organization_name_form = OrgNameForm(
organization_name=InstanceConfig.get_default().organization_name)
logo_form = LogoForm()
Expand Down Expand Up @@ -67,6 +76,7 @@ def manage_config() -> Union[str, werkzeug.Response]:
return render_template("config.html",
submission_preferences_form=submission_preferences_form,
organization_name_form=organization_name_form,
max_len=Submission.MAX_MESSAGE_LEN,
logo_form=logo_form)

@view.route('/update-submission-preferences', methods=['POST'])
Expand All @@ -75,9 +85,17 @@ def update_submission_preferences() -> Optional[werkzeug.Response]:
form = SubmissionPreferencesForm()
if form.validate_on_submit():
# The UI prompt ("prevent") is the opposite of the setting ("allow"):
allow_uploads = not form.prevent_document_uploads.data

if form.prevent_short_messages.data:
msg_length = form.min_message_length.data
else:
msg_length = 0

reject_codenames = form.reject_codename_messages.data

InstanceConfig.update_submission_prefs(allow_uploads, msg_length, reject_codenames)
flash(gettext("Preferences saved."), "submission-preferences-success")
value = not bool(request.form.get('prevent_document_uploads'))
InstanceConfig.set_allow_document_uploads(value)
return redirect(url_for('admin.manage_config') + "#config-preventuploads")
else:
for field, errors in list(form.errors.items()):
Expand Down
52 changes: 46 additions & 6 deletions securedrop/journalist_app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,40 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms import Field
from wtforms import (TextAreaField, StringField, BooleanField, HiddenField,
from wtforms import (TextAreaField, StringField, BooleanField, HiddenField, IntegerField,
ValidationError)
from wtforms.validators import InputRequired, Optional, DataRequired, StopValidation

from models import Journalist, InstanceConfig, HOTP_SECRET_LENGTH

from typing import Any

import typing


class RequiredIf(DataRequired):

def __init__(self, other_field_name: str, *args: Any, **kwargs: Any) -> None:
def __init__(self,
other_field_name: str,
custom_message: typing.Optional[str] = None,
*args: Any, **kwargs: Any) -> None:

self.other_field_name = other_field_name
if custom_message is not None:
self.custom_message = custom_message
else:
self.custom_message = ""

def __call__(self, form: FlaskForm, field: Field) -> None:
if self.other_field_name in form:
other_field = form[self.other_field_name]
if bool(other_field.data):
self.message = gettext(
'The "{name}" field is required when "{other_name}" is set.'
.format(other_name=self.other_field_name, name=field.name))
if self.custom_message != "":
self.message = self.custom_message
else:
self.message = gettext(
'The "{name}" field is required when "{other_name}" is set.'
.format(other_name=self.other_field_name, name=field.name))
super(RequiredIf, self).__call__(form, field)
else:
field.errors[:] = []
Expand Down Expand Up @@ -89,6 +102,12 @@ def check_invalid_usernames(form: FlaskForm, field: Field) -> None:
"This username is invalid because it is reserved for internal use by the software."))


def check_message_length(form: FlaskForm, field: Field) -> None:
msg_len = field.data
if not isinstance(msg_len, int) or msg_len < 0:
raise ValidationError(gettext("Please specify an integer value greater than 0."))


class NewUserForm(FlaskForm):
username = StringField('username', validators=[
InputRequired(message=gettext('This field is required.')),
Expand Down Expand Up @@ -121,7 +140,28 @@ class ReplyForm(FlaskForm):


class SubmissionPreferencesForm(FlaskForm):
prevent_document_uploads = BooleanField('prevent_document_uploads')
prevent_document_uploads = BooleanField(
'prevent_document_uploads',
false_values=('false', 'False', '')
)
prevent_short_messages = BooleanField(
'prevent_short_messages',
false_values=('false', 'False', '')
)
min_message_length = IntegerField('min_message_length',
validators=[
RequiredIf(
'prevent_short_messages',
gettext("To configure a minimum message length, "
"you must set the required number of "
"characters.")),
check_message_length],
render_kw={
'aria-describedby': 'message-length-notes'})
reject_codename_messages = BooleanField(
'reject_codename_messages',
false_values=('false', 'False', '')
)


class OrgNameForm(FlaskForm):
Expand Down
23 changes: 21 additions & 2 deletions securedrop/journalist_templates/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,28 @@ <h2 id="config-preventuploads">{{ gettext('Submission Preferences') }}</h2>

<form action="{{ url_for('admin.update_submission_preferences') }}" method="post">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
{{ submission_preferences_form.prevent_document_uploads() }}
<label

<div class="config_form_element">
{{ submission_preferences_form.prevent_document_uploads() }}
<label
for="prevent_document_uploads">{{ gettext('Prevent sources from uploading documents. Sources will still be able to send messages.') }}</label>
</div>

<div class="config_form_element">
{{ submission_preferences_form.prevent_short_messages() }}
<label
for="prevent_short_messages">{{ gettext('Prevent sources from sending initial messages shorter than the minimum required length:') }}</label>
<div class="config_form_subelement">
{{ submission_preferences_form.min_message_length(min=0, max=max_len) }}
<label class="form-field-hint"
for="min_message_length">{{ gettext('Minimum number of characters.') }}</label>
</div>
</div>
<div class="config_form_element">
{{ submission_preferences_form.reject_codename_messages() }}
<label
for="reject_codename_messages">{{ gettext('Prevent sources from submitting their codename as an initial message.') }}</label>
</div>
<div class="section-spacing">
<button type="submit" id="submit-submission-preferences" class="icon icon-edit"
aria-label="{{ gettext('Update Submission Preferences') }}">
Expand Down
45 changes: 28 additions & 17 deletions securedrop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from passlib.hash import argon2
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, backref, Query, RelationshipProperty
from sqlalchemy import Column, Index, Integer, String, Boolean, DateTime, LargeBinary
from sqlalchemy import Column, Integer, String, Boolean, DateTime, LargeBinary
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound

Expand Down Expand Up @@ -932,24 +932,36 @@ class RevokedToken(db.Model):

class InstanceConfig(db.Model):
'''Versioned key-value store of settings configurable from the journalist
interface. The current version has valid_until=None.
interface. The current version has valid_until=0 (unix epoch start)
'''

# Limits length of org name used in SI and JI titles, image alt texts etc.
MAX_ORG_NAME_LEN = 64

__tablename__ = 'instance_config'
version = Column(Integer, primary_key=True)
valid_until = Column(DateTime, default=None, unique=True)
valid_until = Column(DateTime, default=datetime.datetime.fromtimestamp(0),
nullable=False, unique=True)
allow_document_uploads = Column(Boolean, default=True)
organization_name = Column(String(255), nullable=True, default="SecureDrop")
initial_message_min_len = Column(Integer, default=0)
reject_message_with_codename = Column(Boolean, default=False)

# Columns not listed here will be included by InstanceConfig.copy() when
# updating the configuration.
metadata_cols = ['version', 'valid_until']

def __repr__(self) -> str:
return "<InstanceConfig(version=%s, valid_until=%s)>" % (self.version, self.valid_until)
return "<InstanceConfig(version=%s, valid_until=%s, " \
"allow_document_uploads=%s, organization_name=%s, " \
"initial_message_min_len=%s, reject_message_with_codename=%s)>" % (
self.version,
self.valid_until,
self.allow_document_uploads,
self.organization_name,
self.initial_message_min_len,
self.reject_message_with_codename
)

def copy(self) -> "InstanceConfig":
'''Make a copy of only the configuration columns of the given
Expand Down Expand Up @@ -981,15 +993,15 @@ def get_current(cls) -> "InstanceConfig":
'''

try:
return cls.query.filter(cls.valid_until == None).one() # lgtm [py/test-equals-none] # noqa: E711, E501
return cls.query.filter(cls.valid_until == datetime.datetime.fromtimestamp(0)).one()
except NoResultFound:
try:
current = cls()
db.session.add(current)
db.session.commit()
return current
except IntegrityError:
return cls.query.filter(cls.valid_until == None).one() # lgtm [py/test-equals-none] # noqa: E711, E501
return cls.query.filter(cls.valid_until == datetime.datetime.fromtimestamp(0)).one()

@classmethod
def check_name_acceptable(cls, name: str) -> None:
Expand Down Expand Up @@ -1017,25 +1029,24 @@ def set_organization_name(cls, name: str) -> None:
db.session.commit()

@classmethod
def set_allow_document_uploads(cls, value: bool) -> None:
def update_submission_prefs(
cls,
allow_uploads: bool,
min_length: int,
reject_codenames: bool
) -> None:
'''Invalidate the current configuration and append a new one with the
requested change.
updated submission preferences.
'''

old = cls.get_current()
old.valid_until = datetime.datetime.utcnow()
db.session.add(old)

new = old.copy()
new.allow_document_uploads = value
new.allow_document_uploads = allow_uploads
new.initial_message_min_len = min_length
new.reject_message_with_codename = reject_codenames
db.session.add(new)

db.session.commit()


one_active_instance_config_index = Index(
'ix_one_active_instance_config',
InstanceConfig.valid_until.is_(None),
unique=True,
sqlite_where=(InstanceConfig.valid_until.is_(None))
)
21 changes: 18 additions & 3 deletions securedrop/sass/journalist.sass
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@
.content div
align-content: center

.config_form_subelement
margin-left: 1.5em
margin-bottom: 0

.config_form_element
padding: 3px
margin-bottom: 1.0em

#min_message_length
width: 6em

.form-field-hint
font-size: 0.9em
font-style: italic

#replies
.reply
border: 1px solid $color_grey_medium
Expand Down Expand Up @@ -203,7 +218,7 @@ button.button-star.un-starred
background-image: url(/static/icons/unstarred.png)
width: 16px
height: 15px

&:active, &:hover
span:before
background-image: url(/static/icons/starred.png)
Expand Down Expand Up @@ -232,8 +247,8 @@ button.icon-reset:not([data-tooltip]), button.icon-reset[data-tooltip] > span.la
width: 15px
height: 15px

.icon-bell
.icon-bell
&:before
background-image: url(/static/icons/bell.png)
width: 13px
height: 15px
height: 15px
5 changes: 5 additions & 0 deletions securedrop/sass/source.sass
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@ p#max-file-size
font-style: italic
margin-bottom: 0

p#min-msg-length
font-size: 10px
font-style: italic
margin-bottom: 12px

.bubble
width: 415px
position: absolute
Expand Down
Loading