CVE-2021-25965: Cross-Site Request Forgery (CSRF) (CWE-352)
value: In Calibre-web, versions 0.6.0 to 0.6.13 are vulnerable to Cross-Site Request Forgery (CSRF). By luring an authenticated user to click on a link, an attacker can create a new user role with admin privileges and attacker-controlled credentials, allowing them to take over the application.
CWE description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
CVSS v3 base score: 8.8
Published: 2021-11-16T10:15Z
Repository: https://github.com/janeczku/calibre-web
Language: HTML
Files changed: cps.py, cps/__init__.py, cps/about.py, cps/kobo.py, cps/static/js/edit_books.js, cps/static/js/main.js, cps/templates/book_edit.html, cps/templates/book_table.html, cps/templates/config_db.html, cps/templates/config_edit.html, cps/templates/config_view_edit.html, cps/templates/detail.html, cps/templates/email_edit.html, cps/templates/http_error.html, cps/templates/layout.html, cps/templates/list.html, cps/templates/login.html, cps/templates/register.html, cps/templates/search_form.html, cps/templates/shelf_edit.html, cps/templates/user_edit.html, cps/templates/user_table.html, cps/web.py, requirements.txt, setup.cfg
Commit date: 2021-10-04 19:23:20 +0200
Commit message: Added handling for missing flask-wtf dependency
Added CSRF protection (via flask-wtf)
Moved upload function to js file
Fixed error page in case of csrf failure

Diff: diff --git a/cps.py b/cps.py
index e4b1ede8..161b1655 100755
--- a/cps.py
+++ b/cps.py
@@ -47,11 +47,11 @@ from cps.error_handler import init_errorhandler
 
 try:
     from cps.kobo import kobo, get_kobo_activated
     from cps.kobo_auth import kobo_auth
     kobo_available = get_kobo_activated()
-except ImportError:
+except (ImportError, AttributeError):   # Catch also error for not installed flask-wtf (missing csrf decorator)
     kobo_available = False
 
 try:
     from cps.oauth_bb import oauth
     oauth_available = True
diff --git a/cps/__init__.py b/cps/__init__.py
index 517358c5..f1143d9d 100644
--- a/cps/__init__.py
+++ b/cps/__init__.py
@@ -41,10 +41,16 @@ try:
     import lxml
     lxml_present = True
 except ImportError:
     lxml_present = False
 
+try:
+    from flask_wtf.csrf import CSRFProtect
+    wtf_present = True
+except ImportError:
+    wtf_present = False
+
 mimetypes.init()
 mimetypes.add_type('application/xhtml+xml', '.xhtml')
 mimetypes.add_type('application/epub+zip', '.epub')
 mimetypes.add_type('application/fb2+zip', '.fb2')
 mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
@@ -73,10 +79,16 @@ app.config.update(
 lm = LoginManager()
 lm.login_view = 'web.login'
 lm.anonymous_user = ub.Anonymous
 lm.session_protection = 'strong'
 
+if wtf_present:
+    csrf = CSRFProtect()
+    csrf.init_app(app)
+else:
+    csrf = None
+
 ub.init_db(cli.settingspath)
 # pylint: disable=no-member
 config = config_sql.load_configuration(ub.session)
 
 web_server = WebServer()
@@ -103,10 +115,15 @@ def create_app():
         sys.exit(5)
     if not lxml_present:
         log.info('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***')
         print('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***')
         sys.exit(6)
+    if not wtf_present:
+        log.info('*** "flask-wtf" is needed for calibre-web to run. Please install it using pip: "pip install flask-wtf" ***')
+        print('*** "flask-wtf" is needed for calibre-web to run. Please install it using pip: "pip install flask-wtf" ***')
+        sys.exit(7)
+
     app.wsgi_app = ReverseProxied(app.wsgi_app)
     # For python2 convert path to unicode
     if sys.version_info < (3, 0):
         app.static_folder = app.static_folder.decode('utf-8')
         app.root_path = app.root_path.decode('utf-8')
diff --git a/cps/about.py b/cps/about.py
index 66c0ef40..31a16552 100644
--- a/cps/about.py
+++ b/cps/about.py
@@ -27,10 +27,14 @@ import sqlite3
 from collections import OrderedDict
 
 import babel, pytz, requests, sqlalchemy
 import werkzeug, flask, flask_login, flask_principal, jinja2
 from flask_babel import gettext as _
+try:
+    from flask_wtf import __version__ as flaskwtf_version
+except ImportError:
+    flaskwtf_version = _(u'not installed')
 
 from . import db, calibre_db, converter, uploader, server, isoLanguages, constants
 from .render_template import render_title_template
 try:
     from flask_login import __version__ as flask_loginVersion
@@ -73,27 +77,28 @@ _VERSIONS = OrderedDict(
                 + constants.NIGHTLY_VERSION[1].replace('%','%%'),
     WebServer=server.VERSION,
     Flask=flask.__version__,
     Flask_Login=flask_loginVersion,
     Flask_Principal=flask_principal.__version__,
+    Flask_WTF=flaskwtf_version,
     Werkzeug=werkzeug.__version__,
     Babel=babel.__version__,
     Jinja2=jinja2.__version__,
     Requests=requests.__version__,
     SqlAlchemy=sqlalchemy.__version__,
     pySqlite=sqlite3.version,
     SQLite=sqlite3.sqlite_version,
     iso639=isoLanguages.__version__,
     pytz=pytz.__version__,
-    Unidecode = unidecode_version,
-    Scholarly = scholarly_version,
-    Flask_SimpleLDAP =  u'installed' if bool(services.ldap) else None,
-    python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
-    Goodreads = u'installed' if bool(services.goodreads_support) else None,
-    jsonschema = services.SyncToken.__version__  if bool(services.SyncToken) else None,
-    flask_dance = flask_danceVersion,
-    greenlet = greenlet_Version
+    Unidecode=unidecode_version,
+    Scholarly=scholarly_version,
+    Flask_SimpleLDAP=u'installed' if bool(services.ldap) else None,
+    python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None,
+    Goodreads=u'installed' if bool(services.goodreads_support) else None,
+    jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None,
+    flask_dance=flask_danceVersion,
+    greenlet=greenlet_Version
 )
 _VERSIONS.update(uploader.get_versions())
 
 
 def collect_stats():
diff --git a/cps/kobo.py b/cps/kobo.py
index 7eee3e17..ba9a8d1f 100644
--- a/cps/kobo.py
+++ b/cps/kobo.py
@@ -45,11 +45,12 @@ from sqlalchemy import func
 from sqlalchemy.sql.expression import and_, or_
 from sqlalchemy.exc import StatementError
 from sqlalchemy.sql import select
 import requests
 
-from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub
+
+from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf
 from .constants import sqlalchemy_version2
 from .helper import get_download_link
 from .services import SyncToken as SyncToken
 from .web import download_required
 from .kobo_auth import requires_kobo_auth, get_auth_token
@@ -503,11 +504,11 @@ def get_metadata(book):
             "Id": uuid.uuid3(uuid.NAMESPACE_DNS, name),
         }
 
     return metadata
 
-
+@csrf.exempt
 @kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
 @requires_kobo_auth
 # Creates a Shelf with the given items, and returns the shelf's uuid.
 def HandleTagCreate():
     # catch delete requests, otherwise the are handeld in the book delete handler
@@ -593,10 +594,11 @@ def add_items_to_shelf(items, shelf):
         except KeyError:
             items_unknown_to_calibre.append(item)
     return items_unknown_to_calibre
 
 
+@csrf.exempt
 @kobo.route("/v1/library/tags/<tag_id>/items", methods=["POST"])
 @requires_kobo_auth
 def HandleTagAddItem(tag_id):
     items = None
     try:
@@ -622,10 +624,11 @@ def HandleTagAddItem(tag_id):
     ub.session.merge(shelf)
     ub.session_commit()
     return make_response('', 201)
 
 
+@csrf.exempt
 @kobo.route("/v1/library/tags/<tag_id>/items/delete", methods=["POST"])
 @requires_kobo_auth
 def HandleTagRemoveItem(tag_id):
     items = None
     try:
@@ -981,20 +984,22 @@ def HandleUnimplementedRequest(dummy=None):
     log.debug("Unimplemented Library Request received: %s", request.base_url)
     return redirect_or_proxy_request()
 
 
 # TODO: Implement the following routes
+@csrf.exempt
 @kobo.route("/v1/user/loyalty/<dummy>", methods=["GET", "POST"])
 @kobo.route("/v1/user/profile", methods=["GET", "POST"])
 @kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
 @kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
 @kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
 def HandleUserRequest(dummy=None):
     log.debug("Unimplemented User Request received: %s", request.base_url)
     return redirect_or_proxy_request()
 
 
+@csrf.exempt
 @kobo.route("/v1/products/<dummy>/prices", methods=["GET", "POST"])
 @kobo.route("/v1/products/<dummy>/recommendations", methods=["GET", "POST"])
 @kobo.route("/v1/products/<dummy>/nextread", methods=["GET", "POST"])
 @kobo.route("/v1/products/<dummy>/reviews", methods=["GET", "POST"])
 @kobo.route("/v1/products/books/external/<dummy>", methods=["GET", "POST"])
@@ -1024,10 +1029,11 @@ def make_calibre_web_auth_response():
             }
         )
     )
 
 
+@csrf.exempt
 @kobo.route("/v1/auth/device", methods=["POST"])
 @requires_kobo_auth
 def HandleAuthRequest():
     log.debug('Kobo Auth request')
     if config.config_kobo_proxy:
diff --git a/cps/static/js/edit_books.js b/cps/static/js/edit_books.js
index 00c971d3..66dd4eea 100644
--- a/cps/static/js/edit_books.js
+++ b/cps/static/js/edit_books.js
@@ -21,11 +21,10 @@ if ($(".tiny_editor").length) {
     });
 }
 
 $(".datepicker").datepicker({
     format: "yyyy-mm-dd",
-    language: language
 }).on("change", function () {
     // Show localized date over top of the standard YYYY-MM-DD date
     var pubDate;
     var results = /(\d{4})[-\/\\](\d{1,2})[-\/\\](\d{1,2})/.exec(this.value); // YYYY-MM-DD
     if (results) {
diff --git a/cps/static/js/main.js b/cps/static/js/main.js
index ea068423..2ffaaa87 100644
--- a/cps/static/js/main.js
+++ b/cps/static/js/main.js
@@ -110,10 +110,18 @@ $(".container-fluid").bind('drop', function (e) {
 
 $("#btn-upload").change(function() {
     $("#form-upload").submit();
 });
 
+$("#form-upload").uploadprogress({
+    redirect_url: getPath() + "/", //"{{ url_for('web.index')}}",
+    uploadedMsg: $("#form-upload").data("message"), //"{{_('Upload done, processing, please wait...')}}",
+    modalTitle: $("#form-upload").data("title"), //"{{_('Uploading...')}}",
+    modalFooter: $("#form-upload").data("footer"), //"{{_('Close')}}",
+    modalTitleFailed: $("#form-upload").data("failed") //"{{_('Error')}}"
+});
+
 $(document).ready(function() {
   var inp = $('#query').first()
   if (inp.length) {
     var val = inp.val()
     if (val.length) {
@@ -221,10 +229,20 @@ $(function() {
     // Allow ajax prefilters to be added/removed dynamically
     // eslint-disable-next-line new-cap
     var preFilters = $.Callbacks();
     $.ajaxPrefilter(preFilters.fire);
 
+    // equip all post requests with csrf_token
+    var csrftoken = $("input[name='csrf_token']").val();
+    $.ajaxSetup({
+        beforeSend: function(xhr, settings) {
+            if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
+                xhr.setRequestHeader("X-CSRFToken", csrftoken)
+            }
+        }
+    });
+
     function restartTimer() {
         $("#spinner").addClass("hidden");
         $("#RestartDialog").modal("hide");
     }
 
@@ -574,11 +592,11 @@ $(function() {
         this.blur();
         $.ajax({
             method:"post",
             dataType: "json",
             url: window.location.pathname + "/../../ajax/simulatedbchange",
-            data: {config_calibre_dir: $("#config_calibre_dir").val()},
+            data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()},
             success: function success(data) {
                 if ( data.change ) {
                     if ( data.valid ) {
                         confirmDialog(
                         "db_submit",
@@ -710,11 +728,11 @@ $(function() {
         e.stopPropagation();
         $.ajax({
             method:"post",
             contentType: "application/json; charset=utf-8",
             dataType: "json",
-            url: window.location.pathname + "/../ajax/view",
+            url: getPath() + "/ajax/view",
             data: "{\"series\": {\"series_view\": \""+ view +"\"}}",
             success: function success() {
                 location.reload();
             }
         });
diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html
index 04eb2c02..a47f400a 100644
--- a/cps/templates/book_edit.html
+++ b/cps/templates/book_edit.html
@@ -21,10 +21,11 @@
 {% endif %}
 
 {%  if source_formats|length > 0 and conversion_formats|length > 0 %}
   <div class="text-center more-stuff"><h4>{{_('Convert book format:')}}</h4>
       <form class="padded-bottom" action="{{ url_for('editbook.convert_bookformat', book_id=book.id) }}" method="post" id="book_convert_frm">
+          <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
           <div class="form-group">
               <div class="text-left">
                   <label class="control-label" for="book_format_from">{{_('Convert from:')}}</label>
                   <select class="form-control" name="book_format_from" id="book_format_from">
                     <option disabled selected value>-- {{_('select an option')}} --</option>
diff --git a/cps/templates/book_table.html b/cps/templates/book_table.html
index bbcd0ed6..d7cee112 100644
--- a/cps/templates/book_table.html
+++ b/cps/templates/book_table.html
@@ -18,10 +18,11 @@
 <link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
 <link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
 {% endblock %}
 {% block body %}
 <h2 class="{{page}}">{{_(title)}}</h2>
+      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
       <div class="col-xs-12 col-sm-6">
         <div class="row form-group">
           <div class="btn btn-default disabled" id="merge_books" aria-disabled="true">{{_('Merge selected books')}}</div>
           <div class="btn btn-default disabled" id="delete_selection" aria-disabled="true">{{_('Remove Selections')}}</div>
         </div>
diff --git a/cps/templates/config_db.html b/cps/templates/config_db.html
index c5027abf..0090bd95 100644
--- a/cps/templates/config_db.html
+++ b/cps/templates/config_db.html
@@ -6,10 +6,11 @@
 {% endblock %}
 {% block body %}
 <div class="discover">
   <h2>{{title}}</h2>
   <form role="form" method="POST" class="col-md-10 col-lg-6" action="{{ url_for('admin.db_configuration') }}" autocomplete="off">
+       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <label for="config_calibre_dir">{{_('Location of Calibre Database')}}</label>
        <div class="form-group required input-group">
         <input type="text" class="form-control" id="config_calibre_dir" name="config_calibre_dir" value="{% if config.config_calibre_dir != None %}{{ config.config_calibre_dir }}{% endif %}" autocomplete="off">
         <span class="input-group-btn">
           <button type="button" data-toggle="modal" id="calibre_modal_path" data-link="config_calibre_dir" data-filefilter="metadata.db" data-target="#fileModal" id="library_path" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html
index e062fae5..8cd0034e 100644
--- a/cps/templates/config_edit.html
+++ b/cps/templates/config_edit.html
@@ -6,10 +6,11 @@
 {% endblock %}
 {% block body %}
 <div class="discover">
   <h2>{{title}}</h2>
 <form role="form" method="POST" autocomplete="off">
+<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 <div class="panel-group col-md-10 col-lg-8">
   <div class="panel panel-default">
     <div class="panel-heading">
       <h4 class="panel-title">
         <a class="accordion-toggle" data-toggle="collapse" href="#collapseone">
diff --git a/cps/templates/config_view_edit.html b/cps/templates/config_view_edit.html
index 2ea1c53c..b2578312 100644
--- a/cps/templates/config_view_edit.html
+++ b/cps/templates/config_view_edit.html
@@ -4,12 +4,13 @@
 <link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
 {% endblock %}
 {% block body %}
 <div class="discover">
   <h2>{{title}}</h2>
-  <form role="form" method="POST" autocomplete="off" >
-<div class="panel-group class="col-md-10 col-lg-6">
+<form role="form" method="POST" autocomplete="off" >
+<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+<div class="panel-group" class="col-md-10 col-lg-6">
   <div class="panel panel-default">
     <div class="panel-heading">
       <h4 class="panel-title">
         <a class="accordion-toggle" data-toggle="collapse" href="#collapsefour">
           <span class="glyphicon glyphicon-plus"></span>
diff --git a/cps/templates/detail.html b/cps/templates/detail.html
index 4a3d8f23..6dd1c72a 100644
--- a/cps/templates/detail.html
+++ b/cps/templates/detail.html
@@ -212,19 +212,21 @@
       {% if not g.user.is_anonymous %}
 
         <div class="custom_columns">
           <p>
           <form id="have_read_form" action="{{ url_for('web.toggle_read', book_id=entry.id)}}" method="POST">
+            <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
             <label class="block-label">
               <input id="have_read_cb" data-checked="{{_('Mark As Unread')}}" data-unchecked="{{_('Mark As Read')}}" type="checkbox" {% if have_read %}checked{% endif %} >
               <span>{{_('Read')}}</span>
             </label>
           </form>
           </p>
           {% if g.user.check_visibility(32768) %}
           <p>
             <form id="archived_form" action="{{ url_for('web.toggle_archived', book_id=entry.id)}}" method="POST">
+              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
               <label class="block-label">
                 <input id="archived_cb" data-checked="{{_('Restore from archive')}}" data-unchecked="{{_('Add to archive')}}" type="checkbox" {% if is_archived %}checked{% endif %} >
                 <span>{{_('Archived')}}</span>
               </label>
             </form>
diff --git a/cps/templates/email_edit.html b/cps/templates/email_edit.html
index 884d9689..698aa6ad 100644
--- a/cps/templates/email_edit.html
+++ b/cps/templates/email_edit.html
@@ -5,10 +5,11 @@
 {% endblock %}
 {% block body %}
 <div class="discover">
   <h1>{{title}}</h1>
   <form role="form" class="col-md-10 col-lg-6" method="POST">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     {% if feature_support['gmail'] %}
     <div class="form-group">
       <label for="config_email_type">{{_('Choose Server Type')}}</label>
       <select name="mail_server_type" id="config_email_type" class="form-control" data-control="email-settings">
        <option value="0" {% if content.mail_server_type == 0 %}selected{% endif %}>{{_('Use Standard E-Mail Account')}}</option>
@@ -70,10 +71,11 @@
   </form>
     {% if g.allow_registration %}
   <div class="col-md-10 col-lg-6">
     <h2>{{_('Allowed Domains (Whitelist)')}}</h2>
     <form id="domain_add_allow" action="{{ url_for('admin.add_domain',allow=1)}}" method="POST">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     <div class="form-group required">
       <label for="domainname_allow">{{_('Add Domain')}}</label>
       <input type="text" class="form-control" name="domainname" id="domainname_allow" >
     </div>
     <button id="domain_allow_submit" class="btn btn-default">{{_('Add')}}</button>
@@ -96,15 +98,16 @@
             <th data-align="right" data-formatter="TableActions"></th>
         </tr>
       </thead>
     </table>
     <form id="domain_add_deny" action="{{ url_for('admin.add_domain',allow=0)}}" method="POST">
-    <div class="form-group required">
-      <label for="domainname_deny">{{_('Add Domain')}}</label>
-      <input type="text" class="form-control" name="domainname" id="domainname_deny" >
-    </div>
-    <button id="domain_deny_submit" class="btn btn-default">{{_('Add')}}</button>
+      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+      <div class="form-group required">
+        <label for="domainname_deny">{{_('Add Domain')}}</label>
+        <input type="text" class="form-control" name="domainname" id="domainname_deny" >
+      </div>
+      <button id="domain_deny_submit" class="btn btn-default">{{_('Add')}}</button>
     </form>
     </div>
 
     {% endif %}
 </div>
diff --git a/cps/templates/http_error.html b/cps/templates/http_error.html
index eb628a5f..8abd0c16 100644
--- a/cps/templates/http_error.html
+++ b/cps/templates/http_error.html
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
-<html class="http-error" lang="{{ g.user.locale }}">
+<html class="http-error">
   <head>
     <title>{{ instance }} | HTTP Error ({{ error_code }})</title>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
diff --git a/cps/templates/layout.html b/cps/templates/layout.html
index a7315c34..7ec79f45 100644
--- a/cps/templates/layout.html
+++ b/cps/templates/layout.html
@@ -59,11 +59,11 @@
           <ul class="nav navbar-nav navbar-right" id="main-nav">
             {% if g.user.is_authenticated or g.allow_anonymous %}
               {% if g.user.role_upload() or g.user.role_admin()%}
                 {% if g.allow_upload %}
                   <li>
-                    <form id="form-upload" class="navbar-form" action="{{ url_for('editbook.upload') }}" method="post" enctype="multipart/form-data">
+                    <form id="form-upload" class="navbar-form" action="{{ url_for('editbook.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
                       <div class="form-group">
                         <span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
                         type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple></span>
                       </div>
                     </form>
@@ -198,21 +198,10 @@
     <script src="{{ url_for('static', filename='js/libs/intention.min.js') }}"></script>
     <script src="{{ url_for('static', filename='js/libs/context.min.js') }}"></script>
     <script src="{{ url_for('static', filename='js/libs/plugins.js') }}"></script>
     <script src="{{ url_for('static', filename='js/libs/jquery.form.min.js') }}"></script>
     <script src="{{ url_for('static', filename='js/uploadprogress.js') }}"> </script>
-    <script type="text/javascript">
-        $(function() {
-            $("#form-upload").uploadprogress({
-              redirect_url: "{{ url_for('web.index')}}",
-              uploadedMsg: "{{_('Upload done, processing, please wait...')}}",
-              modalTitle: "{{_('Uploading...')}}",
-              modalFooter: "{{_('Close')}}",
-              modalTitleFailed: "{{_('Error')}}"
-            });
-        });
-    </script>
     <script src="{{ url_for('static', filename='js/main.js') }}"></script>
     {% if g.current_theme == 1 %}
       <script src="{{ url_for('static', filename='js/libs/jquery.visible.min.js') }}"></script>
       <script src="{{ url_for('static', filename='js/libs/compromise.min.js') }}"></script>
       <script src="{{ url_for('static', filename='js/libs/readmore.min.js') }}"></script>
diff --git a/cps/templates/list.html b/cps/templates/list.html
index 599b330b..a8be24ec 100644
--- a/cps/templates/list.html
+++ b/cps/templates/list.html
@@ -18,10 +18,11 @@
         <button class="btn btn-primary char">{{char.char}}</button>
         {% endfor %}
       </div>
 
       {% if data == "series" %}
+      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
       <button class="update-view btn btn-primary" data-target="series_view" id="grid-button" data-view="grid">Grid</button>
       {% endif %}
     </div>
   <div class="container">
     <div id="list" class="col-xs-12 col-sm-6">
diff --git a/cps/templates/login.html b/cps/templates/login.html
index 7ae56d1b..0d5232e3 100644
--- a/cps/templates/login.html
+++ b/cps/templates/login.html
@@ -2,10 +2,11 @@
 {% block body %}
 <div class="well col-sm-6 col-sm-offset-2">
   <h2 style="margin-top: 0">{{_('Login')}}</h2>
   <form method="POST" role="form">
     <input type="hidden" name="next" value="{{next_url}}">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     <div class="form-group">
       <label for="username">{{_('Username')}}</label>
       <input type="text" class="form-control" id="username" name="username" placeholder="{{_('Username')}}">
     </div>
     <div class="form-group">
diff --git a/cps/templates/register.html b/cps/templates/register.html
index db8644fb..307a209b 100644
--- a/cps/templates/register.html
+++ b/cps/templates/register.html
@@ -1,10 +1,11 @@
 {% extends "layout.html" %}
 {% block body %}
 <div class="well col-sm-6 col-sm-offset-2">
   <h2 style="margin-top: 0">{{_('Register New Account')}}</h2>
   <form method="POST" role="form">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     {% if not config.config_register_email %}
     <div class="form-group required">
       <label for="name">{{_('Username')}}</label>
       <input type="text" class="form-control" id="name" name="name" placeholder="{{_('Choose a username')}}" required>
     </div>
diff --git a/cps/templates/search_form.html b/cps/templates/search_form.html
index 5d186ba7..fa57c85b 100644
--- a/cps/templates/search_form.html
+++ b/cps/templates/search_form.html
@@ -1,10 +1,11 @@
 {% extends "layout.html" %}
 {% block body %}
 <h1 class="{{page}}">{{title}}</h1>
 <div class="col-md-10 col-lg-6">
   <form role="form" id="search" action="{{ url_for('web.advanced_search_form') }}" method="POST">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     <div class="form-group">
       <label for="book_title">{{_('Book Title')}}</label>
       <input type="text" class="form-control" name="book_title" id="book_title" value="">
     </div>
     <div class="form-group">
diff --git a/cps/templates/shelf_edit.html b/cps/templates/shelf_edit.html
index b3c81731..2882d08a 100644
--- a/cps/templates/shelf_edit.html
+++ b/cps/templates/shelf_edit.html
@@ -1,10 +1,11 @@
 {% extends "layout.html" %}
 {% block body %}
 <div class="discover">
   <h1>{{title}}</h1>
   <form role="form" method="POST">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     <div class="form-group">
       <label for="title">{{_('Title')}}</label>
       <input type="text" class="form-control" name="title" id="title" value="{{ shelf.name if shelf.name != None }}">
     </div>
     {% if g.user.role_edit_shelfs() %}
diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html
index b4429faf..d48ba9a2 100644
--- a/cps/templates/user_edit.html
+++ b/cps/templates/user_edit.html
@@ -1,10 +1,11 @@
 {% extends "layout.html" %}
 {% block body %}
 <div class="discover">
   <h1>{{title}}</h1>
   <form role="form" method="POST" autocomplete="off">
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     <div class="col-md-10 col-lg-8">
     {% if new_user or ( g.user and content.name != "Guest" and g.user.role_admin() ) %}
     <div class="form-group required">
       <label for="name">{{_('Username')}}</label>
       <input type="text" class="form-control" name="name" id="name" value="{{ content.name if content.name != None }}" autocomplete="off">
diff --git a/cps/templates/user_table.html b/cps/templates/user_table.html
index 111d2a09..416f6b7b 100644
--- a/cps/templates/user_table.html
+++ b/cps/templates/user_table.html
@@ -116,10 +116,11 @@
 <link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
 <link href="{{ url_for('static', filename='css/libs/bootstrap-select.min.css') }}" rel="stylesheet" >
 {% endblock %}
 {% block body %}
 <h2 class="{{page}}">{{_(title)}}</h2>
+    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
     <div class="col-xs-12 col-sm-12">
         <div class="row">
           <div class="btn btn-default disabled" id="user_delete_selection" aria-disabled="true">{{_('Remove Selections')}}</div>
         </div>
     </div>
diff --git a/cps/web.py b/cps/web.py
index 3681c38c..db0be841 100644
--- a/cps/web.py
+++ b/cps/web.py
@@ -82,18 +82,17 @@ except ImportError:
     sort = sorted  # Just use regular sort then, may cause issues with badly named pages in cbz/cbr files
 
 
 @app.after_request
 def add_security_headers(resp):
-    resp.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval';"
+    resp.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:"
     if request.endpoint == "editbook.edit_book":
-        resp.headers['Content-Security-Policy'] += "img-src * data:"
+        resp.headers['Content-Security-Policy'] += " *"
     resp.headers['X-Content-Type-Options'] = 'nosniff'
     resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
     resp.headers['X-XSS-Protection'] = '1; mode=block'
     resp.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
-    # log.debug(request.full_path)
     return resp
 
 web = Blueprint('web', __name__)
 log = logger.create()
 
diff --git a/requirements.txt b/requirements.txt
index e7f67593..0332185d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,5 +11,6 @@ requests>=2.11.1,<2.25.0
 SQLAlchemy>=1.3.0,<1.5.0
 tornado>=4.1,<6.2
 Wand>=0.4.4,<0.7.0
 unidecode>=0.04.19,<1.3.0
 lxml>=3.8.0,<4.7.0
+flask-wtf>=0.15.0,<0.16.0
diff --git a/setup.cfg b/setup.cfg
index 9787caa4..cea3f5a4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,13 +16,15 @@ license = GPLv3+
 license_file = LICENSE
 classifiers =
     Development Status :: 5 - Production/Stable
     License :: OSI Approved :: GNU Affero General Public License v3
     Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.5
     Programming Language :: Python :: 3.6
     Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
     Operating System :: OS Independent
 keywords =
   calibre
   calibre-web
   library
@@ -47,10 +49,11 @@ install_requires =
     SQLAlchemy>=1.3.0,<1.5.0
     tornado>=4.1,<6.2
     Wand>=0.4.4,<0.7.0
     unidecode>=0.04.19,<1.3.0
     lxml>=3.8.0,<4.7.0
+    flask-wtf>=0.15.0,<0.16.0
 
 [options.extras_require]
 gdrive =
     google-api-python-client>=1.7.11,<2.1.0
     gevent>20.6.0,<21.2.0

Vulnerable code: except ImportError:
    Unidecode = unidecode_version,
    Scholarly = scholarly_version,
    Flask_SimpleLDAP =  u'installed' if bool(services.ldap) else None,
    python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
    Goodreads = u'installed' if bool(services.goodreads_support) else None,
    jsonschema = services.SyncToken.__version__  if bool(services.SyncToken) else None,
    flask_dance = flask_danceVersion,
    greenlet = greenlet_Version
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub

    language: language
            data: {config_calibre_dir: $("#config_calibre_dir").val()},
            url: window.location.pathname + "/../ajax/view",
  <form role="form" method="POST" autocomplete="off" >
<div class="panel-group class="col-md-10 col-lg-6">
    <div class="form-group required">
      <label for="domainname_deny">{{_('Add Domain')}}</label>
      <input type="text" class="form-control" name="domainname" id="domainname_deny" >
    </div>
    <button id="domain_deny_submit" class="btn btn-default">{{_('Add')}}</button>
<html class="http-error" lang="{{ g.user.locale }}">
                    <form id="form-upload" class="navbar-form" action="{{ url_for('editbook.upload') }}" method="post" enctype="multipart/form-data">
    <script type="text/javascript">
        $(function() {
            $("#form-upload").uploadprogress({
              redirect_url: "{{ url_for('web.index')}}",
              uploadedMsg: "{{_('Upload done, processing, please wait...')}}",
              modalTitle: "{{_('Uploading...')}}",
              modalFooter: "{{_('Close')}}",
              modalTitleFailed: "{{_('Error')}}"
            });
        });
    </script>
    resp.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval';"
        resp.headers['Content-Security-Policy'] += "img-src * data:"
    # log.debug(request.full_path)
    Programming Language :: Python :: 3.5
Fixed code: except (ImportError, AttributeError):   # Catch also error for not installed flask-wtf (missing csrf decorator)
try:
    from flask_wtf.csrf import CSRFProtect
    wtf_present = True
except ImportError:
    wtf_present = False

if wtf_present:
    csrf = CSRFProtect()
    csrf.init_app(app)
else:
    csrf = None

    if not wtf_present:
        log.info('*** "flask-wtf" is needed for calibre-web to run. Please install it using pip: "pip install flask-wtf" ***')
        print('*** "flask-wtf" is needed for calibre-web to run. Please install it using pip: "pip install flask-wtf" ***')
        sys.exit(7)

try:
    from flask_wtf import __version__ as flaskwtf_version
except ImportError:
    flaskwtf_version = _(u'not installed')
    Flask_WTF=flaskwtf_version,
    Unidecode=unidecode_version,
    Scholarly=scholarly_version,
    Flask_SimpleLDAP=u'installed' if bool(services.ldap) else None,
    python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None,
    Goodreads=u'installed' if bool(services.goodreads_support) else None,
    jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None,
    flask_dance=flask_danceVersion,
    greenlet=greenlet_Version

from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf
@csrf.exempt
@csrf.exempt
@csrf.exempt
@csrf.exempt
@csrf.exempt
@csrf.exempt
$("#form-upload").uploadprogress({
    redirect_url: getPath() + "/", //"{{ url_for('web.index')}}",
    uploadedMsg: $("#form-upload").data("message"), //"{{_('Upload done, processing, please wait...')}}",
    modalTitle: $("#form-upload").data("title"), //"{{_('Uploading...')}}",
    modalFooter: $("#form-upload").data("footer"), //"{{_('Close')}}",
    modalTitleFailed: $("#form-upload").data("failed") //"{{_('Error')}}"
});

    // equip all post requests with csrf_token
    var csrftoken = $("input[name='csrf_token']").val();
    $.ajaxSetup({
        beforeSend: function(xhr, settings) {
            if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken)
            }
        }
    });

            data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()},
            url: getPath() + "/ajax/view",
          <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<form role="form" method="POST" autocomplete="off" >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="panel-group" class="col-md-10 col-lg-6">
            <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
      <div class="form-group required">
        <label for="domainname_deny">{{_('Add Domain')}}</label>
        <input type="text" class="form-control" name="domainname" id="domainname_deny" >
      </div>
      <button id="domain_deny_submit" class="btn btn-default">{{_('Add')}}</button>
<html class="http-error">
                    <form id="form-upload" class="navbar-form" action="{{ url_for('editbook.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    resp.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:"
        resp.headers['Content-Security-Policy'] += " *"
flask-wtf>=0.15.0,<0.16.0
    Programming Language :: Python :: 3.8
    Programming Language :: Python :: 3.9
    Programming Language :: Python :: 3.10
    flask-wtf>=0.15.0,<0.16.0