CVE-2021-4164 (published 2022-01-17T13:15Z)

calibre-web is vulnerable to Cross-Site Request Forgery (CSRF)

Severity: MEDIUM
CVSS v2 base score: 6.8 (AV:N/AC:M/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-352
cwe_name: Cross-Site Request Forgery (CSRF)
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.
extended_description: When a web server is designed to receive a request from a client without any mechanism for verifying that it was intentionally sent, then it might be possible for an attacker to trick a client into making an unintentional request to the web server which will be treated as an authentic request. This can be done via a URL, image load, XMLHttpRequest, etc. and can result in exposure of data or unintended code execution.
         

Fix information:
repo_url: https://github.com/janeczku/calibre-web
commit_details: repo_url: https://github.com/janeczku/calibre-web
author: Ozzieisaacs
author_date: 2021-12-25 10:09:11+01:00
msg: Migrated some routes to POST
- delete shelf, import ldap users
- delete_kobo token, kobo force full sync
- shutdown, reconnect, shutdown
repository: repo_url: https://github.com/janeczku/calibre-web
repo_name: janeczku/calibre-web
description: :books: Web app for browsing, reading and downloading eBooks stored in a Calibre database
repo_language: Fluent
owner: janeczku
file_changes: filename: admin.py
change_type: ModificationType.MODIFY
diff: @@ -129,11 +129,11 @@ 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
@@ -906,7 +906,7 @@ def list_restriction(res_type, user_id):
     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()
@@ -1626,7 +1626,7 @@ def edit_user(user_id):
                                  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):
@@ -1802,7 +1802,7 @@ def ldap_import_create_user(user, user_data):
         return 0, message
 
 
-@admi.route('/import_ldap_users')
+@admi.route('/import_ldap_users', methods=["POST"])
 @login_required
 @admin_required
 def import_ldap_users():

programming_language: Python
filename: editbooks.py
change_type: ModificationType.MODIFY
diff: @@ -26,6 +26,8 @@ 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:
@@ -51,13 +53,6 @@ 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()
@@ -237,14 +232,14 @@ def modify_identifiers(input_identifiers, db_identifiers, db_session):
             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)
@@ -1014,7 +1009,7 @@ def move_coverfile(meta, db_book):
               category="error")
 
 
-@editbook.route("/upload", methods=["GET", "POST"])
+@editbook.route("/upload", methods=["POST"])
 @login_required_if_no_ano
 @upload_required
 def upload():

programming_language: Python
filename: kobo_auth.py
change_type: ModificationType.MODIFY
diff: @@ -62,6 +62,7 @@ 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
@@ -70,11 +71,6 @@ 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()
 
@@ -167,7 +163,7 @@ def generate_auth_token(user_id):
         )
 
 
-@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.

programming_language: Python
filename: shelf.py
change_type: ModificationType.MODIFY
diff: @@ -56,7 +56,7 @@ def check_shelf_view_permissions(cur_shelf):
     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'
@@ -112,7 +112,7 @@ def add_to_shelf(shelf_id, book_id):
     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()
@@ -164,7 +164,7 @@ def search_to_shelf(shelf_id):
     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'
@@ -323,12 +323,13 @@ def delete_shelf_helper(cur_shelf):
     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")

programming_language: Python
filename: main.js
change_type: ModificationType.MODIFY
diff: @@ -179,7 +179,7 @@ $("#delete_confirm").click(function() {
         if (ajaxResponse) {
             path = getPath() + "/ajax/delete/" + deleteId;
             $.ajax({
-                method:"get",
+                method:"post",
                 url: path,
                 timeout: 900,
                 success:function(data) {
@@ -376,9 +376,11 @@ $(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);
@@ -387,9 +389,11 @@ $(function() {
     });
     $("#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);
             }
@@ -447,9 +451,11 @@ $(function() {
         $("#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);
@@ -527,7 +533,7 @@ $(function() {
             $(this).data('value'),
             function (value) {
                 $.ajax({
-                    method: "get",
+                    method: "post",
                     url: getPath() + "/kobo_auth/deleteauthtoken/" + value,
                 });
                 $("#config_delete_kobo_token").hide();
@@ -574,7 +580,7 @@ $(function() {
             function(value){
                 path = getPath() + "/ajax/fullsync"
                 $.ajax({
-                    method:"get",
+                    method:"post",
                     url: path,
                     timeout: 900,
                     success:function(data) {
@@ -638,7 +644,7 @@ $(function() {
                     else {
                         $("#InvalidDialog").modal('show');
                     }
-                } else {                	
+                } else {
                     changeDbSettings();
                 }
             }
@@ -685,7 +691,7 @@ $(function() {
             "GeneralDeleteModal",
             $(this).data('value'),
             function(value){
-                window.location.href = window.location.pathname + "/../../shelf/delete/" + value
+                $("#delete_shelf").closest("form").submit()
             }
         );
 
@@ -734,7 +740,8 @@ $(function() {
         $("#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) {

programming_language: JavaScript
filename: shelf.html
change_type: ModificationType.MODIFY
diff: @@ -2,14 +2,16 @@
 {% 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>
@@ -84,22 +86,6 @@
     {% 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() }}

programming_language: HTML
filename: web.py
change_type: ModificationType.MODIFY
diff: @@ -1055,7 +1055,8 @@ def get_tasks_status():
     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({})
@@ -1435,7 +1436,7 @@ def download_link(book_id, book_format, anyname):
     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):

programming_language: Python