Repository: freedomofpress/securedrop

Issue:
Source "X" can delete replies to source "Y" via the source interface
# Steps to reproduce

1. Boot dev env
1. Create source X, submit
1. Open private tab, create source Y, submit
1. Login as a journalist, reply to both
1. In the `/lookup` page for source X, inspect the reply form and copy the hidden input `reply_filename`
1. In the `/lookup` page for source Y, alter the reply form to have the copied value, submit/confirm
1. Reload X's `/lookup` page and see that the reply is missing

## Expected Behavior

X cannot delete Y's reply

## Actual Behavior

X **can** delete Y's reply

# Relevant code

`securedrop/source_app/main.py`

```python
    @view.route('/delete', methods=('POST',))
    @login_required
    def delete():
        """This deletes the reply from the source's inbox, but preserves
        the history for journalists such that they can view conversation
        history.
        """

        query = Reply.query.filter(
            Reply.filename == request.form['reply_filename'])
        reply = get_one_or_else(query, current_app.logger, abort)
        reply.deleted_by_source = True
        db.session.add(reply)
        db.session.commit()

        flash(gettext("Reply deleted"), "notification")
        return redirect(url_for('.lookup'))
```

Looking at the `Reply.query.filter`, you can see that we do not check that a given response belongs to a source.

# `git blame`

Not blaming, but listing it here for the eventual report to save y'all some time.

This was introduced in commit `83d2329b77` on 2015-02-15.

# Analysis

Any authenticated source can delete replies belonging to any other source. However, they must know the filename, but we don't throttle anything on the source interface so a determined attacker could in theory disrupt source/journalist communication.

Estimated entropy of the filename:

```
>>> # num * nouns * adjectives * {doc,msg}
>>> math.log(100 *17949 * 8222 * 2, 2)
34.780745694714774
```

It seems feasible that a determined attacker could disrupt communications with some frequency on a high traffic instance.


Discussion:

Resolving patch:
diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py
--- a/securedrop/source_app/main.py
+++ b/securedrop/source_app/main.py
@@ -212,8 +212,9 @@ def delete():
         history.
         """
 
-        query = Reply.query.filter(
-            Reply.filename == request.form['reply_filename'])
+        query = Reply.query.filter_by(
+            filename=request.form['reply_filename'],
+            source_id=g.source.id)
         reply = get_one_or_else(query, current_app.logger, abort)
         reply.deleted_by_source = True
         db.session.add(reply)


Test patch:
diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py
--- a/securedrop/tests/test_source.py
+++ b/securedrop/tests/test_source.py
@@ -5,7 +5,7 @@
 import subprocess
 
 from cStringIO import StringIO
-from flask import session, escape, current_app, url_for
+from flask import session, escape, current_app, url_for, g
 from mock import patch, ANY
 
 import crypto_util
@@ -647,3 +647,47 @@ def test_csrf_error_page(config, source_app):
         resp = app.post(url_for('main.create'), follow_redirects=True)
         text = resp.data.decode('utf-8')
         assert 'Your session timed out due to inactivity' in text
+
+
+def test_source_can_only_delete_own_replies(source_app):
+    '''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()
+    source1, codename1 = utils.db_helper.init_source()
+    journalist, _ = utils.db_helper.init_journalist()
+    replies = utils.db_helper.reply(journalist, source0, 1)
+    filename = replies[0].filename
+    confirmation_msg = 'Reply deleted'
+
+    with source_app.test_client() as app:
+        resp = app.post(url_for('main.login'),
+                        data={'codename': codename1},
+                        follow_redirects=True)
+        assert resp.status_code == 200
+        assert g.source.id == source1.id
+
+        resp = app.post(url_for('main.delete'),
+                        data={'reply_filename': filename},
+                        follow_redirects=True)
+        assert resp.status_code == 404
+        assert confirmation_msg not in resp.data.decode('utf-8')
+
+    reply = Reply.query.filter_by(filename=filename).one()
+    assert not reply.deleted_by_source
+
+    with source_app.test_client() as app:
+        resp = app.post(url_for('main.login'),
+                        data={'codename': codename0},
+                        follow_redirects=True)
+        assert resp.status_code == 200
+        assert g.source.id == source0.id
+
+        resp = app.post(url_for('main.delete'),
+                        data={'reply_filename': filename},
+                        follow_redirects=True)
+        assert resp.status_code == 200
+        assert confirmation_msg in resp.data.decode('utf-8')
+
+    reply = Reply.query.filter_by(filename=filename).one()
+    assert reply.deleted_by_source