Repository: freedomofpress/securedrop

Issue:
DELETE methods not allowed by apache journalist interface config
## Description

This is the issue causing https://github.com/freedomofpress/securedrop-client/issues/173

## Steps to Reproduce

1. Provision staging VMs
2. Get a valid API token [[ref]](https://gist.github.com/redshiftzero/934093c4f8e0f47c53e1add475345e34) 
3. Add a source via the source interface
4. Attempt to remove a star from a source (this is an idempotent operation so one should not need to first add the star to hit the endpoint):

```
curl -X DELETE -H "Content-Type: application/json" -H "Authorization: Token mytokenhere" --proxy socks5h://127.0.0.1:9150 mystagingserver.onion/api/v1/sources/my-source-uuid-here/remove_star
```

## Expected Behavior

`{"message":"Star removed"}`

## Actual Behavior

A 403 occurs, and we get redirected to the login page. 

This redirect-for-403 behavior only happens for the API in staging because Apache is [sending 403s to `/notfound`](https://github.com/freedomofpress/securedrop/blame/eaa2d45d371b64d5bb82317209d686c63063fec8/install_files/ansible-base/roles/app/templates/sites-available/journalist.conf#L42), causing the [`request.path`](https://github.com/freedomofpress/securedrop/blob/develop/securedrop/journalist_app/__init__.py#L136) to be `/notfound` (instead of something that begins with `thisismeonion.onion/api/blahhh`), meaning that code execution will continue until [the redirect here](https://github.com/freedomofpress/securedrop/blob/develop/securedrop/journalist_app/__init__.py#L140).

## Comments

The cause is the 403, and this is happening because of the [LimitExcept](https://httpd.apache.org/docs/current/mod/core.html#limitexcept) directive we are using in the Apache configs. These allow only GET POST and HEAD requests.

To prove this to oneself, you can edit `/etc/apache2/sites-enabled/journalist.conf` and add DELETE as an allowed request method.

This is similar to bugs #3772 and #3877. In light of this we should:

1. Revisit #1775 and,
2. Incorporate some API endpoint testing which would have detected all three bugs into at least the pre-release QA process (these could eventually be in CI as part of the external test client described in #3661)


Discussion:
We discussed this bug today in the engineering meeting and decided that the way to resolve is to modify the journalist apache config, and request admins that want to use the journalist API to run `securedrop-admin install`. Otherwise we'll need to do another brittle apache config migration in the `postinst` of `securedrop-app-code` and given that the changes here are more extensive than other migrations we've made, we should avoid it if possible. 

This does mean that we'll need to be extremely careful when making any other Apache config changes in the future (until we have a better story regarding minimizing config drift across instances - insert obligatory ansible pull reference #3136 which is looking like a pretty good way forward in the medium term imho). 
Also, when we resolve this via updating the journalist apache config template, we should also resolve #3877 at that time

Resolving patch:
diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py
--- a/securedrop/journalist_app/api.py
+++ b/securedrop/journalist_app/api.py
@@ -309,7 +309,7 @@ def get_current_user():
         user = get_user_object(request)
         return jsonify(user.to_json()), 200
 
-    def _handle_http_exception(error):
+    def _handle_api_http_exception(error):
         # Workaround for no blueprint-level 404/5 error handlers, see:
         # https://github.com/pallets/flask/issues/503#issuecomment-71383286
         response = jsonify({'error': error.name,
@@ -318,6 +318,6 @@ def _handle_http_exception(error):
         return response, error.code
 
     for code in default_exceptions:
-        api.errorhandler(code)(_handle_http_exception)
+        api.errorhandler(code)(_handle_api_http_exception)
 
     return api


Test patch:
diff --git a/molecule/testinfra/staging/app/apache/test_apache_journalist_interface.py b/molecule/testinfra/staging/app/apache/test_apache_journalist_interface.py
--- a/molecule/testinfra/staging/app/apache/test_apache_journalist_interface.py
+++ b/molecule/testinfra/staging/app/apache/test_apache_journalist_interface.py
@@ -16,7 +16,6 @@
   "Header set X-Content-Security-Policy: \"default-src 'self'\"",
   "Header set Content-Security-Policy: \"default-src 'self'\"",
   'Header set Referrer-Policy "no-referrer"',
-  'Header unset Etag',
 ]
 
 
@@ -51,11 +50,11 @@ def test_apache_headers_journalist_interface(host, header):
 <Directory /var/www/>
   Options None
   AllowOverride None
-  <Limit GET POST HEAD>
+  <Limit GET POST HEAD DELETE>
     Order allow,deny
     allow from {apache_allow_from}
   </Limit>
-  <LimitExcept GET POST HEAD>
+  <LimitExcept GET POST HEAD DELETE>
     Order deny,allow
     Deny from all
   </LimitExcept>
@@ -64,11 +63,11 @@ def test_apache_headers_journalist_interface(host, header):
 <Directory {securedrop_code}>
   Options None
   AllowOverride None
-  <Limit GET POST HEAD>
+  <Limit GET POST HEAD DELETE>
     Order allow,deny
     allow from {apache_allow_from}
   </Limit>
-  <LimitExcept GET POST HEAD>
+  <LimitExcept GET POST HEAD DELETE>
     Order deny,allow
     Deny from all
   </LimitExcept>
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -2024,3 +2024,9 @@ def test_does_set_cookie_headers(journalist_app, test_journo):
         observed_headers = response.headers
         assert 'Set-Cookie' in observed_headers.keys()
         assert 'Cookie' in observed_headers['Vary']
+
+
+def test_app_error_handlers_defined(journalist_app):
+    for status_code in [400, 401, 403, 404, 500]:
+        # This will raise KeyError if an app-wide error handler is not defined
+        assert journalist_app.error_handler_spec[None][status_code]
diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py
--- a/securedrop/tests/test_journalist_api.py
+++ b/securedrop/tests/test_journalist_api.py
@@ -244,7 +244,16 @@ def test_user_without_token_cannot_post_protected_endpoints(journalist_app,
             assert response.status_code == 403
 
 
-def test_api_404(journalist_app, journalist_api_token):
+def test_api_error_handlers_defined(journalist_app):
+    """Ensure the expected error handler is defined in the API blueprint"""
+    for status_code in [400, 401, 403, 404, 500]:
+        result = journalist_app.error_handler_spec['api'][status_code]
+
+        expected_error_handler = '_handle_api_http_exception'
+        assert result.values()[0].__name__ == expected_error_handler
+
+
+def test_api_error_handler_404(journalist_app, journalist_api_token):
     with journalist_app.test_client() as app:
         response = app.get('/api/v1/invalidendpoint',
                            headers=get_api_headers(journalist_api_token))