CVE-2021-4164: Cross-Site Request Forgery (CSRF) (CWE-352)
value: calibre-web is vulnerable to Cross-Site Request Forgery (CSRF)
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: 2022-01-17T13:15Z
Repository: https://github.com/janeczku/calibre-web
Language: Python
Files changed: cps/admin.py, cps/editbooks.py, cps/kobo_auth.py, cps/shelf.py, cps/static/js/main.js, cps/templates/shelf.html, cps/web.py
Commit date: 2021-12-30 14:45:30 +0100
Commit message: Migrated some routes to POST
- delete shelf, import ldap users
- delete_kobo token, kobo force full sync
- shutdown, reconnect, shutdown

Diff: diff --git a/cps/admin.py b/cps/admin.py
index 1c228e49a..29f5fb201 100644
--- a/cps/admin.py
+++ b/cps/admin.py
@@ -127,15 +127,15 @@ def before_request():
 @login_required
 def admin_forbidden():
     abort(403)
 
 
-@admi.route("/shutdown")
+@admi.route("/shutdown", methods=["POST"])
 @login_required
 @admin_required
 def shutdown():
-    task = int(request.args.get("parameter").strip())
+    task = request.get_json().get('parameter', -1)
     showtext = {}
     if task in (0, 1):  # valid commandos received
         # close all database connections
         calibre_db.dispose()
         ub.dispose()
@@ -904,11 +904,11 @@ def list_restriction(res_type, user_id):
     js = json.dumps(json_dumps)
     response = make_response(js)
     response.headers["Content-Type"] = "application/json; charset=utf-8"
     return response
 
-@admi.route("/ajax/fullsync")
+@admi.route("/ajax/fullsync", methods=["POST"])
 @login_required
 def ajax_fullsync():
     count = ub.session.query(ub.KoboSyncedBooks).filter(current_user.id == ub.KoboSyncedBooks.user_id).delete()
     message = _("{} sync entries deleted").format(count)
     ub.session_commit(message)
@@ -1624,11 +1624,11 @@ def edit_user(user_id):
                                  kobo_support=kobo_support,
                                  title=_(u"Edit User %(nick)s", nick=content.name),
                                  page="edituser")
 
 
-@admi.route("/admin/resetpassword/<int:user_id>")
+@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
 @login_required
 @admin_required
 def reset_user_password(user_id):
     if current_user is not None and current_user.is_authenticated:
         ret, message = reset_password(user_id)
@@ -1800,11 +1800,11 @@ def ldap_import_create_user(user, user_data):
         ub.session.rollback()
         message = _(u'Failed to Create at Least One LDAP User')
         return 0, message
 
 
-@admi.route('/import_ldap_users')
+@admi.route('/import_ldap_users', methods=["POST"])
 @login_required
 @admin_required
 def import_ldap_users():
     showtext = {}
     try:
diff --git a/cps/editbooks.py b/cps/editbooks.py
index 21dc3ba0a..b6fdc164e 100644
--- a/cps/editbooks.py
+++ b/cps/editbooks.py
@@ -24,10 +24,12 @@ import os
 from datetime import datetime
 import json
 from shutil import copyfile
 from uuid import uuid4
 from markupsafe import escape
+from functools import wraps
+
 try:
     from lxml.html.clean import clean_html
 except ImportError:
     pass
 
@@ -49,17 +51,10 @@ from . import calibre_db
 from .services.worker import WorkerThread
 from .tasks.upload import TaskUpload
 from .render_template import render_title_template
 from .usermanagement import login_required_if_no_ano
 
-try:
-    from functools import wraps
-except ImportError:
-    pass  # We're not using Python 3
-
-
-
 
 editbook = Blueprint('editbook', __name__)
 log = logger.create()
 
 
@@ -235,18 +230,18 @@ def modify_identifiers(input_identifiers, db_identifiers, db_session):
         if identifier_type not in db_dict.keys():
             db_session.add(identifier)
             changed = True
     return changed, error
 
-@editbook.route("/ajax/delete/<int:book_id>")
+@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
 @login_required
 def delete_book_from_details(book_id):
     return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
 
 
-@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""})
-@editbook.route("/delete/<int:book_id>/<string:book_format>")
+@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
+@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
 @login_required
 def delete_book_ajax(book_id, book_format):
     return delete_book_from_table(book_id, book_format, False)
 
 
@@ -1012,11 +1007,11 @@ def move_coverfile(meta, db_book):
         flash(_(u"Failed to Move Cover File %(file)s: %(error)s", file=new_coverpath,
                 error=e),
               category="error")
 
 
-@editbook.route("/upload", methods=["GET", "POST"])
+@editbook.route("/upload", methods=["POST"])
 @login_required_if_no_ano
 @upload_required
 def upload():
     if not config.config_uploading:
         abort(404)
diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py
index a51095c8d..8d46b26ac 100644
--- a/cps/kobo_auth.py
+++ b/cps/kobo_auth.py
@@ -60,23 +60,19 @@ particular calls to non-Kobo specific endpoints such as the CalibreWeb book down
 """
 
 from binascii import hexlify
 from datetime import datetime
 from os import urandom
+from functools import wraps
 
 from flask import g, Blueprint, url_for, abort, request
 from flask_login import login_user, current_user, login_required
 from flask_babel import gettext as _
 
 from . import logger, config, calibre_db, db, helper, ub, lm
 from .render_template import render_title_template
 
-try:
-    from functools import wraps
-except ImportError:
-    pass  # We're not using Python 3
-
 
 log = logger.create()
 
 
 def register_url_value_preprocessor(kobo):
@@ -165,11 +161,11 @@ def generate_auth_token(user_id):
             ),
             warning = False
         )
 
 
-@kobo_auth.route("/deleteauthtoken/<int:user_id>")
+@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
 @login_required
 def delete_auth_token(user_id):
     # Invalidate any prevously generated Kobo Auth token for this user.
     ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
         .filter(ub.RemoteAuthToken.token_type==1).delete()
diff --git a/cps/shelf.py b/cps/shelf.py
index 5f6a66bf2..19c2d68ec 100644
--- a/cps/shelf.py
+++ b/cps/shelf.py
@@ -54,11 +54,11 @@ def check_shelf_view_permissions(cur_shelf):
         log.error("User is unauthorized to view non-public shelf: %s", cur_shelf)
         return False
     return True
 
 
-@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>")
+@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
 @login_required
 def add_to_shelf(shelf_id, book_id):
     xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
     shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
     if shelf is None:
@@ -110,11 +110,11 @@ def add_to_shelf(shelf_id, book_id):
         else:
             return redirect(url_for('web.index'))
     return "", 204
 
 
-@shelf.route("/shelf/massadd/<int:shelf_id>")
+@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
 @login_required
 def search_to_shelf(shelf_id):
     shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
     if shelf is None:
         log.error("Invalid shelf specified: %s", shelf_id)
@@ -162,11 +162,11 @@ def search_to_shelf(shelf_id):
         log.error("Could not add books to shelf: {}".format(shelf.name))
         flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
     return redirect(url_for('web.index'))
 
 
-@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>")
+@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
 @login_required
 def remove_from_shelf(shelf_id, book_id):
     xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
     shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
     if shelf is None:
@@ -321,16 +321,17 @@ def delete_shelf_helper(cur_shelf):
     ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
     ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
     ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name))
 
 
-@shelf.route("/shelf/delete/<int:shelf_id>")
+@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
 @login_required
 def delete_shelf(shelf_id):
     cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
     try:
         delete_shelf_helper(cur_shelf)
+        flash(_("Shelf successfully deleted"), category="success")
     except InvalidRequestError:
         ub.session.rollback()
         log.error("Settings DB is not Writeable")
         flash(_("Settings DB is not Writeable"), category="error")
     return redirect(url_for('web.index'))
diff --git a/cps/static/js/main.js b/cps/static/js/main.js
index 585d2296a..6cef22f8e 100644
--- a/cps/static/js/main.js
+++ b/cps/static/js/main.js
@@ -177,11 +177,11 @@ $("#delete_confirm").click(function() {
         window.location.href = getPath() + "/delete/" + deleteId + "/" + bookFormat;
     } else {
         if (ajaxResponse) {
             path = getPath() + "/ajax/delete/" + deleteId;
             $.ajax({
-                method:"get",
+                method:"post",
                 url: path,
                 timeout: 900,
                 success:function(data) {
                     data.forEach(function(item) {
                         if (!jQuery.isEmptyObject(item)) {
@@ -374,24 +374,28 @@ $(function() {
         }
     }
 
     $("#restart").click(function() {
         $.ajax({
+            method:"post",
+            contentType: "application/json; charset=utf-8",
             dataType: "json",
-            url: window.location.pathname + "/../../shutdown",
-            data: {"parameter":0},
+            url: getPath() + "/shutdown",
+            data: JSON.stringify({"parameter":0}),
             success: function success() {
                 $("#spinner").show();
                 setTimeout(restartTimer, 3000);
             }
         });
     });
     $("#shutdown").click(function() {
         $.ajax({
+            method:"post",
+            contentType: "application/json; charset=utf-8",
             dataType: "json",
-            url: window.location.pathname + "/../../shutdown",
-            data: {"parameter":1},
+            url: getPath() + "/shutdown",
+            data: JSON.stringify({"parameter":1}),
             success: function success(data) {
                 return alert(data.text);
             }
         });
     });
@@ -445,13 +449,15 @@ $(function() {
         $("#DialogHeader").addClass("hidden");
         $("#DialogFinished").addClass("hidden");
         $("#DialogContent").html("");
         $("#spinner2").show();
         $.ajax({
+            method:"post",
+            contentType: "application/json; charset=utf-8",
             dataType: "json",
             url: getPath() + "/shutdown",
-            data: {"parameter":2},
+            data: JSON.stringify({"parameter":2}),
             success: function success(data) {
                 $("#spinner2").hide();
                 $("#DialogContent").html(data.text);
                 $("#DialogFinished").removeClass("hidden");
             }
@@ -525,11 +531,11 @@ $(function() {
             $(this).attr('id'),
             "GeneralDeleteModal",
             $(this).data('value'),
             function (value) {
                 $.ajax({
-                    method: "get",
+                    method: "post",
                     url: getPath() + "/kobo_auth/deleteauthtoken/" + value,
                 });
                 $("#config_delete_kobo_token").hide();
                 $("#kobo_full_sync").hide();
             }
@@ -572,11 +578,11 @@ $(function() {
             "GeneralDeleteModal",
             $(this).data('value'),
             function(value){
                 path = getPath() + "/ajax/fullsync"
                 $.ajax({
-                    method:"get",
+                    method:"post",
                     url: path,
                     timeout: 900,
                     success:function(data) {
                         data.forEach(function(item) {
                             if (!jQuery.isEmptyObject(item)) {
@@ -636,11 +642,11 @@ $(function() {
                         );
                     }
                     else {
                         $("#InvalidDialog").modal('show');
                     }
-                } else {                	
+                } else {
                     changeDbSettings();
                 }
             }
         });
     });
@@ -683,11 +689,11 @@ $(function() {
         confirmDialog(
             $(this).attr('id'),
             "GeneralDeleteModal",
             $(this).data('value'),
             function(value){
-                window.location.href = window.location.pathname + "/../../shelf/delete/" + value
+                $("#delete_shelf").closest("form").submit()
             }
         );
 
     });
 
@@ -732,11 +738,12 @@ $(function() {
         $("#DialogHeader").addClass("hidden");
         $("#DialogFinished").addClass("hidden");
         $("#DialogContent").html("");
         $("#spinner2").show();
         $.ajax({
-            method:"get",
+            method:"post",
+            contentType: "application/json; charset=utf-8",
             dataType: "json",
             url: getPath() + "/import_ldap_users",
             success: function success(data) {
                 $("#spinner2").hide();
                 $("#DialogContent").html(data.text);
diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html
index 4f74a4da2..5bb9317ef 100644
--- a/cps/templates/shelf.html
+++ b/cps/templates/shelf.html
@@ -1,17 +1,19 @@
 {% extends "layout.html" %}
 {% block body %}
 <div class="discover">
   <h2>{{title}}</h2>
+      <form action="{{url_for('shelf.delete_shelf', shelf_id=shelf.id)}}" method="post">
   {% if g.user.role_download() %}
   <a id="shelf_down" href="{{ url_for('shelf.show_simpleshelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Download') }} </a>
       {% endif %}
   {% if g.user.is_authenticated %}
     {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public  %}
-      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
-      <div class="btn btn-danger" id="delete_shelf" data-value="{{ shelf.id }}">{{ _('Delete this Shelf') }}</div>
+        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+        <div class="btn btn-danger" id="delete_shelf" data-value="{{ shelf.id }}">{{ _('Delete this Shelf') }}</div>
       <a id="edit_shelf" href="{{ url_for('shelf.edit_shelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Edit Shelf Properties') }} </a>
+      </form>
       {% if entries.__len__() %}
       <a id="order_shelf" href="{{ url_for('shelf.order_shelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Arrange books manually') }} </a>
       <button id="toggle_order_shelf" type="button" data-alt-text="{{ _('Disable Change order') }}" class="btn btn-primary">{{ _('Enable Change order') }}</button>
         <div class="filterheader hidden-xs">
           <a data-toggle="tooltip" title="{{_('Sort according to book date, newest first')}}" id="new" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='new')}}"><span class="glyphicon glyphicon-book"></span> <span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order"></span></a>
@@ -82,25 +84,9 @@
       </div>
     </div>
     {% endfor %}
   </div>
 </div>
-<!--div id="DeleteShelfDialog" class="modal fade" role="dialog">
-  <div class="modal-dialog modal-sm">
-    <div class="modal-content">
-      <div class="modal-header bg-danger text-center">
-      <span>{{_('Are you sure you want to delete this shelf?')}}</span>
-      </div>
-      <div class="modal-body text-center">
-        <span>{{_('Shelf will be deleted for all users')}}</span>
-          <p></p>
-        <a id="confirm" href="{{ url_for('shelf.delete_shelf', shelf_id=shelf.id) }}" class="btn btn-danger">{{_('OK')}}</a>
-        <button type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
-      </div>
-    </div>
-  </div>
-</div-->
-
 {% endblock %}
 {% block modal %}
 {{ delete_confirm_modal() }}
 {% endblock %}
diff --git a/cps/web.py b/cps/web.py
index f203783b6..81c377114 100644
--- a/cps/web.py
+++ b/cps/web.py
@@ -1053,11 +1053,12 @@ def get_tasks_status():
     tasks = WorkerThread.getInstance().tasks
     answer = render_task_status(tasks)
     return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")
 
 
-@app.route("/reconnect")
+# method is available without login and not protected by CSRF to make it easy reachable
+@app.route("/reconnect", methods=['GET'])
 def reconnect():
     calibre_db.reconnect_db(config, ub.app_DB_path)
     return json.dumps({})
 
 
@@ -1433,11 +1434,11 @@ def serve_book(book_id, book_format, anyname):
 def download_link(book_id, book_format, anyname):
     client = "kobo" if "Kobo" in request.headers.get('User-Agent') else ""
     return get_download_link(book_id, book_format, client)
 
 
-@web.route('/send/<int:book_id>/<book_format>/<int:convert>')
+@web.route('/send/<int:book_id>/<book_format>/<int:convert>', methods=["POST"])
 @login_required
 @download_required
 def send_to_kindle(book_id, book_format, convert):
     if not config.get_mail_server_configured():
         flash(_(u"Please configure the SMTP mail settings first..."), category="error")

Vulnerable code: @admi.route("/shutdown")
    task = int(request.args.get("parameter").strip())
@admi.route("/ajax/fullsync")
@admi.route("/admin/resetpassword/<int:user_id>")
@admi.route('/import_ldap_users')
try:
    from functools import wraps
except ImportError:
    pass  # We're not using Python 3



@editbook.route("/ajax/delete/<int:book_id>")
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""})
@editbook.route("/delete/<int:book_id>/<string:book_format>")
@editbook.route("/upload", methods=["GET", "POST"])
try:
    from functools import wraps
except ImportError:
    pass  # We're not using Python 3

@kobo_auth.route("/deleteauthtoken/<int:user_id>")
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>")
@shelf.route("/shelf/massadd/<int:shelf_id>")
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>")
@shelf.route("/shelf/delete/<int:shelf_id>")
                method:"get",
            url: window.location.pathname + "/../../shutdown",
            data: {"parameter":0},
            url: window.location.pathname + "/../../shutdown",
            data: {"parameter":1},
            data: {"parameter":2},
                    method: "get",
                    method:"get",
                } else {                	
                window.location.href = window.location.pathname + "/../../shelf/delete/" + value
            method:"get",
      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
      <div class="btn btn-danger" id="delete_shelf" data-value="{{ shelf.id }}">{{ _('Delete this Shelf') }}</div>
<!--div id="DeleteShelfDialog" class="modal fade" role="dialog">
  <div class="modal-dialog modal-sm">
    <div class="modal-content">
      <div class="modal-header bg-danger text-center">
      <span>{{_('Are you sure you want to delete this shelf?')}}</span>
      </div>
      <div class="modal-body text-center">
        <span>{{_('Shelf will be deleted for all users')}}</span>
          <p></p>
        <a id="confirm" href="{{ url_for('shelf.delete_shelf', shelf_id=shelf.id) }}" class="btn btn-danger">{{_('OK')}}</a>
        <button type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
      </div>
    </div>
  </div>
</div-->

@app.route("/reconnect")
@web.route('/send/<int:book_id>/<book_format>/<int:convert>')
Fixed code: @admi.route("/shutdown", methods=["POST"])
    task = request.get_json().get('parameter', -1)
@admi.route("/ajax/fullsync", methods=["POST"])
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
@admi.route('/import_ldap_users', methods=["POST"])
from functools import wraps

@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
@editbook.route("/upload", methods=["POST"])
from functools import wraps
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
        flash(_("Shelf successfully deleted"), category="success")
                method:"post",
            method:"post",
            contentType: "application/json; charset=utf-8",
            url: getPath() + "/shutdown",
            data: JSON.stringify({"parameter":0}),
            method:"post",
            contentType: "application/json; charset=utf-8",
            url: getPath() + "/shutdown",
            data: JSON.stringify({"parameter":1}),
            method:"post",
            contentType: "application/json; charset=utf-8",
            data: JSON.stringify({"parameter":2}),
                    method: "post",
                    method:"post",
                } else {
                $("#delete_shelf").closest("form").submit()
            method:"post",
            contentType: "application/json; charset=utf-8",
      <form action="{{url_for('shelf.delete_shelf', shelf_id=shelf.id)}}" method="post">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <div class="btn btn-danger" id="delete_shelf" data-value="{{ shelf.id }}">{{ _('Delete this Shelf') }}</div>
      </form>
# method is available without login and not protected by CSRF to make it easy reachable
@app.route("/reconnect", methods=['GET'])
@web.route('/send/<int:book_id>/<book_format>/<int:convert>', methods=["POST"])