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

make file submissions dis/allowable #4879

Merged
Merged
Show file tree
Hide file tree
Changes from 7 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,32 @@
"""add instance_config key-value store

Revision ID: fef03c1ec779
Revises: 3da3fcab826a
Create Date: 2019-10-21 00:43:19.477835

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'fef03c1ec779'
down_revision = '3da3fcab826a'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('instance_config',
sa.Column('name', sa.String(), nullable=False),
sa.Column('value', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('name')
)
# ### end Alembic commands ###

wbaid marked this conversation as resolved.
Show resolved Hide resolved

def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('instance_config')
# ### end Alembic commands ###
10 changes: 9 additions & 1 deletion securedrop/journalist_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from journalist_app.utils import (get_source, logged_in,
JournalistInterfaceSessionInterface,
cleanup_expired_revoked_tokens)
from models import Journalist
from models import InstanceConfig, Journalist
from store import Storage

import typing
Expand Down Expand Up @@ -124,6 +124,14 @@ def _handle_http_exception(error):
def expire_blacklisted_tokens():
return cleanup_expired_revoked_tokens()

@app.before_request
def load_instance_config():
"""Update app.config from the InstanceConfig table."""

instance_config = InstanceConfig.query.all()
settings = dict(map(lambda x: (x.name, x.value), instance_config))
app.config.from_mapping(settings)

@app.before_request
def setup_g():
# type: () -> Optional[Response]
Expand Down
36 changes: 29 additions & 7 deletions securedrop/journalist_app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
from sqlalchemy.orm.exc import NoResultFound

from db import db
from models import Journalist, InvalidUsernameException, FirstOrLastNameError, PasswordError
from models import (InstanceConfig, Journalist, InvalidUsernameException,
FirstOrLastNameError, PasswordError)
from journalist_app.decorators import admin_required
from journalist_app.utils import (make_password, commit_account_changes, set_diceware_password,
validate_hotp_secret, revoke_token)
from journalist_app.forms import LogoForm, NewUserForm
from journalist_app.forms import AllowDocumentUploadsForm, LogoForm, NewUserForm


def make_blueprint(config):
Expand All @@ -28,9 +29,13 @@ def index():
@view.route('/config', methods=('GET', 'POST'))
@admin_required
def manage_config():
form = LogoForm()
if form.validate_on_submit():
f = form.logo.data
allow_document_uploads_form = AllowDocumentUploadsForm(
allow_document_uploads=current_app.config.get(
'ALLOW_DOCUMENT_UPLOADS',
True))
logo_form = LogoForm()
if logo_form.validate_on_submit():
f = logo_form.logo.data
custom_logo_filepath = os.path.join(current_app.static_folder, 'i',
'custom_logo.png')
try:
Expand All @@ -42,10 +47,27 @@ def manage_config():
finally:
return redirect(url_for("admin.manage_config"))
else:
for field, errors in list(form.errors.items()):
for field, errors in list(logo_form.errors.items()):
for error in errors:
flash(error, "logo-error")
return render_template("config.html", form=form)
return render_template("config.html",
allow_document_uploads_form=allow_document_uploads_form,
logo_form=logo_form)

@view.route('/set-allow-document-uploads', methods=['POST'])
@admin_required
def set_allow_document_uploads():
form = AllowDocumentUploadsForm()
if form.validate_on_submit():
# Upsert ALLOW_DOCUMENT_UPLOADS:
setting = InstanceConfig.query.get('ALLOW_DOCUMENT_UPLOADS')
if not setting:
setting = InstanceConfig(name='ALLOW_DOCUMENT_UPLOADS')
wbaid marked this conversation as resolved.
Show resolved Hide resolved
setting.value = bool(request.form.get('allow_document_uploads'))

db.session.add(setting)
db.session.commit()
return redirect(url_for('admin.manage_config'))

@view.route('/add', methods=('GET', 'POST'))
@admin_required
Expand Down
4 changes: 4 additions & 0 deletions securedrop/journalist_app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class ReplyForm(FlaskForm):
)


class AllowDocumentUploadsForm(FlaskForm):
allow_document_uploads = BooleanField('allow_document_uploads')


class LogoForm(FlaskForm):
logo = FileField(validators=[
FileRequired(message=gettext('File required.')),
Expand Down
17 changes: 16 additions & 1 deletion securedrop/journalist_templates/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h2>{{ gettext('Logo Image') }}</h2>
<form method="post" enctype="multipart/form-data">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
<p>
{{ form.logo(id="logo-upload") }}
{{ logo_form.logo(id="logo-upload") }}
<br>
</p>
<h5>
Expand All @@ -41,4 +41,19 @@ <h5>
{% include 'logo_upload_flashed.html' %}
</form>

<hr class="no-line">

<h2>{{ gettext('Document Uploads') }}</h2>

<form action="{{ url_for('admin.set_allow_document_uploads') }}" method="post">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
<p>
{{ allow_document_uploads_form.allow_document_uploads() }}
<label for="allow_document_uploads">{{ gettext('Allow sources to submit documents as well as messages') }}</label>
</p>
<button type="submit" id="submit-allow-document-uploads">
<i class="fas fa-pencil-alt"></i> {{ gettext('UPDATE') }}
</button>
</form>

{% endblock %}
14 changes: 13 additions & 1 deletion securedrop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from passlib.hash import argon2
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, backref
from sqlalchemy import Column, Integer, String, Boolean, DateTime, LargeBinary
from sqlalchemy import Column, Integer, String, Boolean, DateTime, LargeBinary, JSON
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound

from db import db
Expand Down Expand Up @@ -739,3 +739,15 @@ class RevokedToken(db.Model):
id = Column(Integer, primary_key=True)
journalist_id = Column(Integer, ForeignKey('journalists.id'))
token = db.Column(db.Text, nullable=False, unique=True)


class InstanceConfig(db.Model):
'''Key-value store of settings configurable from the journalist interface.
'''

__tablename__ = 'instance_config'
name = Column(String, primary_key=True)
value = Column(JSON)
wbaid marked this conversation as resolved.
Show resolved Hide resolved

def __repr__(self):
return "<InstanceConfig(name='%s', value='%s')>" % (self.name, self.value)
3 changes: 3 additions & 0 deletions securedrop/sass/modules/_snippet.sass
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@

&:focus
outline: none

.wide
width: 100%
11 changes: 10 additions & 1 deletion securedrop/source_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from crypto_util import CryptoUtil
from db import db
from models import Source
from models import InstanceConfig, Source
from request_that_secures_file_uploads import RequestThatSecuresFileUploads
from source_app import main, info, api
from source_app.decorators import ignore_static
Expand Down Expand Up @@ -130,6 +130,15 @@ def check_tor2web():
.format(url=url_for('info.tor2web_warning'))),
"banner-warning")

@app.before_request
@ignore_static
def load_instance_config():
"""Update app.config from the InstanceConfig table."""

instance_config = InstanceConfig.query.all()
settings = dict(map(lambda x: (x.name, x.value), instance_config))
app.config.from_mapping(settings)

@app.before_request
@ignore_static
def setup_g():
Expand Down
5 changes: 4 additions & 1 deletion securedrop/source_app/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import platform

from flask import Blueprint, make_response
from flask import Blueprint, current_app, make_response

import version

Expand All @@ -12,6 +12,9 @@ def make_blueprint(config):
@view.route('/metadata')
def metadata():
meta = {
'allow_document_uploads': current_app.config.get(
'ALLOW_DOCUMENT_UPLOADS',
True),
'gpg_fpr': config.JOURNALIST_KEY,
'sd_version': version.__version__,
'server_os': platform.linux_distribution()[1],
Expand Down
9 changes: 8 additions & 1 deletion securedrop/source_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ def create():
@view.route('/lookup', methods=('GET',))
@login_required
def lookup():
allow_document_uploads = current_app.config.get(
'ALLOW_DOCUMENT_UPLOADS',
True)
replies = []
source_inbox = Reply.query.filter(Reply.source_id == g.source.id) \
.filter(Reply.deleted_by_source == False).all() # noqa
Expand Down Expand Up @@ -121,6 +124,7 @@ def lookup():

return render_template(
'lookup.html',
allow_document_uploads=allow_document_uploads,
codename=g.codename,
replies=replies,
flagged=g.source.flagged,
Expand All @@ -131,9 +135,12 @@ def lookup():
@view.route('/submit', methods=('POST',))
@login_required
def submit():
allow_document_uploads = current_app.config.get(
'ALLOW_DOCUMENT_UPLOADS',
True)
msg = request.form['msg']
fh = None
if 'fh' in request.files:
if allow_document_uploads and 'fh' in request.files:
fh = request.files['fh']

# Don't submit anything if it was an "empty" submission. #878
Expand Down
8 changes: 7 additions & 1 deletion securedrop/source_templates/lookup.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
{% endif %}
</div>

{% if allow_document_uploads %}
<h2 class="headline">{{ gettext('Submit Files or Messages') }}</h2>
<p class="explanation">{{ gettext('You can submit any kind of file, a message, or both.') }}</p>
{% else %}
<h2 class="headline">{{ gettext('Submit Messages') }}</h2>
{% endif %}

<p class="explanation extended-explanation">{{ gettext('If you are already familiar with GPG, you can optionally encrypt your files and messages with our <a href="{url}" class="text-link">public key</a> before submission. Files are encrypted as they are received by SecureDrop.').format(url=url_for('info.download_journalist_pubkey')) }}
wbaid marked this conversation as resolved.
Show resolved Hide resolved
{{ gettext('<a href="{url}" class="text-link">Learn more</a>.').format(url=url_for('info.why_download_journalist_pubkey')) }}</p>
Expand All @@ -26,12 +30,14 @@ <h2 class="headline">{{ gettext('Submit Files or Messages') }}</h2>
<form id="upload" method="post" action="{{ url_for('main.submit') }}" enctype="multipart/form-data" autocomplete="off">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
<div class="snippet">
{% if allow_document_uploads %}
<div class="attachment grid-item center">
<img class="center" id="upload-icon" src="{{ url_for('static', filename='i/arrow-upload-large.png') }}" width="56" height="56">
<input type="file" name="fh" autocomplete="off">
<p class="center" id="max-file-size">{{ gettext('Maximum upload size: 500 MB') }}</p>
</div>
<div class="message grid-item">
{% endif %}
<div class="message grid-item{% if not allow_document_uploads %} wide{% endif %}">
<textarea name="msg" class="fill-parent" placeholder="{{ gettext('Write a message.') }}"></textarea>
</div>
</div>
Expand Down
40 changes: 40 additions & 0 deletions securedrop/tests/migrations/migration_fef03c1ec779.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from sqlalchemy import text
from sqlalchemy.exc import OperationalError

from db import db
from journalist_app import create_app


instance_config_sql = "SELECT * FROM instance_config"


class UpgradeTester:
def __init__(self, config):
self.config = config
self.app = create_app(config)

def load_data(self):
pass

def check_upgrade(self):
with self.app.app_context():
db.engine.execute(text(instance_config_sql)).fetchall()


class DowngradeTester:
def __init__(self, config):
self.config = config
self.app = create_app(config)

def load_data(self):
pass

def check_downgrade(self):
with self.app.app_context():
try:
db.engine.execute(text(instance_config_sql)).fetchall()

# The SQLite driver appears to return this rather than the
# expected NoSuchTableError.
except OperationalError:
wbaid marked this conversation as resolved.
Show resolved Hide resolved
pass
2 changes: 2 additions & 0 deletions securedrop/tests/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ def __getattr__(self, name):
return getattr(config, name)
not_translated = 'code hello i18n'
with source_app.create_app(Config()).test_client() as c:
with c.application.app_context():
db.create_all()
c.get('/')
assert not_translated == gettext(not_translated)

Expand Down
23 changes: 22 additions & 1 deletion securedrop/tests/test_journalist.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from sdconfig import SDConfig, config

from db import db
from models import (InvalidPasswordLength, Journalist, Reply, Source,
from models import (InvalidPasswordLength, InstanceConfig, Journalist, Reply, Source,
Submission)
from .utils.instrument import InstrumentedApp

Expand Down Expand Up @@ -1287,6 +1287,27 @@ def test_admin_add_user_integrity_error(journalist_app, test_admin, mocker):
"None\n[SQL: STATEMENT]\n[parameters: 'PARAMETERS']") in log_event


def test_allow_document_uploads(journalist_app, test_admin):
wbaid marked this conversation as resolved.
Show resolved Hide resolved
with journalist_app.test_client() as app:
_login_user(app, test_admin['username'], test_admin['password'],
test_admin['otp_secret'])
form = journalist_app_module.forms.AllowDocumentUploadsForm(
allow_document_uploads=True)
app.post(url_for('admin.set_allow_document_uploads'),
data=form.data,
follow_redirects=True)
assert InstanceConfig.query.get('ALLOW_DOCUMENT_UPLOADS').value is True


def test_disallow_document_uploads(journalist_app, test_admin):
with journalist_app.test_client() as app:
_login_user(app, test_admin['username'], test_admin['password'],
test_admin['otp_secret'])
app.post(url_for('admin.set_allow_document_uploads'),
follow_redirects=True)
assert InstanceConfig.query.get('ALLOW_DOCUMENT_UPLOADS').value is False


def test_logo_upload_with_valid_image_succeeds(journalist_app, test_admin):
# Save original logo to restore after test run
logo_image_location = os.path.join(config.SECUREDROP_ROOT,
Expand Down
2 changes: 2 additions & 0 deletions securedrop/tests/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,8 @@ def test_metadata_route(config, source_app):
resp = app.get(url_for('api.metadata'))
assert resp.status_code == 200
assert resp.headers.get('Content-Type') == 'application/json'
assert resp.json.get('allow_document_uploads') ==\
source_app.config.get('ALLOW_DOCUMENT_UPLOADS', True)
assert resp.json.get('sd_version') == version.__version__
assert resp.json.get('server_os') == '16.04'
assert resp.json.get('supported_languages') ==\
Expand Down