diff --git a/config/__init__.py b/config/__init__.py index 9b1e56eea..d78d25e15 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -30,6 +30,9 @@ if not LOCALHOST: SESSION_COOKIE_SECURE = True +# By default, we want to log CSP violations. See /csp-report in views.py. +CSP_LOG = True + # Logging Capabilities # To benefit from the logging, you may want to add: # app.logger.info(Thing_To_Log) @@ -38,6 +41,7 @@ LOG_FILE = '/tmp/webcompat.log' LOG_FMT = '%(asctime)s tracking %(message)s' +CSP_REPORTS_LOG = '/tmp/webcompat-csp-reports.log' # Status categories used in the project # 'new', 'needsdiagnosis', 'needscontact', 'contactready' , 'sitewait', 'close' diff --git a/tests/test_urls.py b/tests/test_urls.py index 917ad6ace..b3f631fb7 100644 --- a/tests/test_urls.py +++ b/tests/test_urls.py @@ -93,6 +93,19 @@ def test_labeler_webhook(self): # A random post should 401, only requests from GitHub will 200 self.assertEqual(rv.status_code, 401) + def test_csp_report_uri(self): + '''Test POST to /csp-report w/ correct content-type returns 204.''' + headers = {'Content-Type': 'application/csp-report'} + rv = self.app.post('/csp-report', headers=headers) + self.assertEqual(rv.status_code, 204) + + def test_csp_report_uri_bad_content_type(self): + '''Test POST w/ wrong content-type to /csp-report returns 400.''' + headers = {'Content-Type': 'application/json'} + rv = self.app.post('/csp-report', headers=headers) + self.assertNotEqual(rv.status_code, 204) + self.assertEqual(rv.status_code, 400) + def test_tools_cssfixme(self): '''Test that the /tools/cssfixme route gets 200.''' rv = self.app.get('/tools/cssfixme') diff --git a/webcompat/helpers.py b/webcompat/helpers.py index e2a94f2eb..b6461e430 100644 --- a/webcompat/helpers.py +++ b/webcompat/helpers.py @@ -487,3 +487,34 @@ def policy(*args, **kwargs): return response return update_wrapper(policy, view) return set_policy + + +def add_sec_headers(response): + '''Add security-related headers to the response. + + This should be used in @app.after_request to ensure the headers are + added to all responses.''' + if not app.config['LOCALHOST']: + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' # nopep8 + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-XSS-Protection'] = '1; mode=block' + response.headers['X-Frame-Options'] = 'DENY' + + +def add_csp(response): + '''Add a Content-Security-Policy header to response. + + This should be used in @app.after_request to ensure the header is + added to all responses.''' + # short term, we send Content-Security-Policy-Report-Only + # see https://github.com/webcompat/webcompat.com/issues/763 for + # sending Content-Security-Policy + response.headers['Content-Security-Policy-Report-Only'] = ( + "default-src 'none'; " + + "connect-src 'self'; " + + "font-src 'self'; " + + "img-src 'self'; " + + "script-src 'self' https://www.google-analytics.com; " + + "style-src 'self'; " + + "report-uri /csp-report" + ) diff --git a/webcompat/views.py b/webcompat/views.py index e6e876f1c..d0a509cd6 100644 --- a/webcompat/views.py +++ b/webcompat/views.py @@ -21,6 +21,8 @@ from form import AUTH_REPORT from form import PROXY_REPORT +from helpers import add_csp +from helpers import add_sec_headers from helpers import cache_policy from helpers import get_browser_name from helpers import get_form @@ -52,6 +54,8 @@ def before_request(): @app.after_request def after_request(response): session_db.remove() + add_sec_headers(response) + add_csp(response) return response @@ -315,3 +319,22 @@ def contributors(): def cssfixme(): '''Route for CSS Fix me tool''' return render_template('cssfixme.html') + + +@app.route('/csp-report', methods=['POST']) +def log_csp_report(): + '''Route to record CSP header violations. + + This route can be enabled/disabled by setting CSP_LOG to True/False + in config/__init__.py. It's enabled by default. + ''' + expected_mime = 'application/csp-report' + + if app.config['CSP_LOG']: + if expected_mime not in request.headers.get('content-type', ''): + return ('Wrong Content-Type.', 400) + with open(app.config['CSP_REPORTS_LOG'], 'a') as r: + r.write(request.data + '\n') + return ('', 204) + else: + return ('Forbidden.', 403)