Repository: Innopoints/backend
Issue category: Security Vulnerability

Implement CSRF protection



Functions to edit:
innopoints/schemas/account.py:AccountSchema
innopoints/views/account.py:get_info
innopoints/views/authentication.py:authorize
innopoints/views/authentication.py:login_cheat
Functions added: innopoints/schemas/account.py:AccountSchema.get_csrf_token

Patch:
diff --git a/innopoints/blueprints.py b/innopoints/blueprints.py
index dc520e8..f5d350e 100644
--- a/innopoints/blueprints.py
+++ b/innopoints/blueprints.py
@@ -5,6 +5,8 @@
 
 from flask import Blueprint
 
+from innopoints.core.helpers import csrf_protect
+
 
 def _factory(partial_module_string, url_prefix='/'):
     """Generates blueprint objects for view modules.
@@ -22,6 +24,7 @@ def _factory(partial_module_string, url_prefix='/'):
 
 
 api = _factory('api', url_prefix='/api/v1')
+api.before_request(csrf_protect)
 auth = _factory('auth')
 
 all_blueprints = (api, auth)
diff --git a/innopoints/core/helpers.py b/innopoints/core/helpers.py
index 38a23d6..a659b61 100644
--- a/innopoints/core/helpers.py
+++ b/innopoints/core/helpers.py
@@ -1,8 +1,12 @@
 """Miscellaneous helper functions."""
 
+import hmac
 import json
 
-from flask import abort as flask_abort, Response
+from flask import abort as flask_abort, Response, session, request
+
+
+MODIFYING_METHODS = ('POST', 'PATCH', 'DELETE')
 
 
 def abort(http_code: int, message=None):
@@ -11,3 +15,12 @@ def abort(http_code: int, message=None):
         flask_abort(Response(json.dumps(message), status=http_code, mimetype='application/json'))
     else:
         flask_abort(Response(status=http_code))
+
+
+def csrf_protect():
+    """Validates the CSRF token for modifying requests (POST, PATCH, DELETE)."""
+    if request.method not in MODIFYING_METHODS:
+        return
+
+    if not hmac.compare_digest(request.headers.get('X-CSRF-Token'), session['csrf_token']):
+        abort(403)
diff --git a/innopoints/schemas/account.py b/innopoints/schemas/account.py
index 3805731..0d2aa8f 100644
--- a/innopoints/schemas/account.py
+++ b/innopoints/schemas/account.py
@@ -14,7 +14,11 @@ class Meta:
         ordered = True
         sqla_session = db.session
 
+    def get_csrf_token(self, _account):
+        return self.context.get('csrf_token')
+
     balance = ma.Int()
+    csrf_token = ma.Method(serialize='get_csrf_token', dump_only=True)
 
 
 class TransactionSchema(ma.ModelSchema):
diff --git a/innopoints/views/account.py b/innopoints/views/account.py
index ec380ee..fe4ff52 100644
--- a/innopoints/views/account.py
+++ b/innopoints/views/account.py
@@ -26,7 +26,7 @@
 import sqlite3
 import math
 
-from flask import request, jsonify
+from flask import request, jsonify, session
 from flask_login import login_required, current_user
 from marshmallow import ValidationError
 from sqlalchemy import or_
@@ -82,8 +82,10 @@ def subquery_to_events(subquery, event_type):
 def get_info(email):
     """Get information about an account.
     If the e-mail is not passed, return information about self."""
+    csrf_token = None
     if email is None:
         user = current_user
+        csrf_token = session['csrf_token']
     else:
         if not current_user.is_admin and email != current_user.email:
             abort(401)
@@ -91,7 +93,8 @@ def get_info(email):
 
     out_schema = AccountSchema(exclude=('moderated_projects', 'created_projects', 'stock_changes',
                                         'transactions', 'applications', 'reports',
-                                        'notification_settings', 'static_files'))
+                                        'notification_settings', 'static_files'),
+                               context={'csrf_token': csrf_token})
     return out_schema.jsonify(user)
 
 
diff --git a/innopoints/views/authentication.py b/innopoints/views/authentication.py
index 0447914..e9a9a70 100644
--- a/innopoints/views/authentication.py
+++ b/innopoints/views/authentication.py
@@ -5,6 +5,7 @@
 - GET /logout
 """
 
+import secrets
 from urllib.parse import urljoin
 
 from flask import current_app, url_for, redirect, session, request
@@ -67,6 +68,7 @@ def authorize():
     db.session.commit()
 
     login_user(user, remember=True)
+    session['csrf_token'] = secrets.token_urlsafe()
 
     final_redirect_uri = session.pop('final_redirect_location', '/')
     frontend_base = session.pop('frontend_base', current_app.config['FRONTEND_BASE'])
@@ -88,6 +90,7 @@ def login_cheat(email):
         abort(400, {'message': 'This endpoint is unavailable.'})
     user = Account.query.get_or_404(email)
     login_user(user, remember=True)
+    session['csrf_token'] = secrets.token_urlsafe()
 
     if 'no_redirect' not in request.args:
         final_redirect_uri = request.args.get('final_redirect_location', '/')