VYPR
High severityNVD Advisory· Published Jan 17, 2022· Updated Aug 3, 2024

Cross-Site Request Forgery (CSRF) in janeczku/calibre-web

CVE-2021-4164

Description

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

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

calibre-web is vulnerable to Cross-Site Request Forgery (CSRF) due to insufficient protection on sensitive operations.

## Vulnerability calibre-web, a web application for browsing and managing Calibre eBook databases, is vulnerable to Cross-Site Request Forgery (CSRF) [2]. The vulnerability affects the application as described in the official record. The specific commit [3] migrated routes such as delete, shutdown, and Kobo authentication token deletion from GET to POST methods, indicating that prior to the fix, these actions were performed via GET requests, making them susceptible to CSRF attacks. Affected versions include those prior to the commit [785726deee13b4d56f6c3503dd57c1e3eb7d6f30] [3].

Exploitation

An attacker can craft a malicious website, email, or link that, when accessed by an authenticated calibre-web user, triggers a request to the vulnerable endpoints without the user's knowledge [2]. Because the vulnerable actions were previously implemented as GET requests, the attacker could embed a simple ` tag or a link that the user's browser would automatically follow, performing the action on behalf of the user. For example, initiating a server shutdown or deleting a book via /ajax/delete/{id}` [3]. No special privileges are needed beyond tricking an authenticated user into interacting with the crafted content.

Impact

Successful exploitation allows an attacker to perform unauthorized actions on behalf of the authenticated user, such as deleting books or shutting down the server [3]. The impact is confined to the permissions of the victim user; however, if the victim is an administrator, the attacker could potentially trigger server-wide disruptive actions [2].

Mitigation

The fix was applied in commit [785726deee13b4d56f6c3503dd57c1e3eb7d6f30] [3] by changing the vulnerable endpoints from GET to POST and adding appropriate CSRF tokens (evidenced by the contentType: "application/json; charset=utf-8" and use of JSON.stringify) [3]. Users should update to a version of calibre-web that includes this commit or later [1]. No KEV listing is reported. If patching is not immediately possible, administrators should enforce strict content security policies and educate users not to click on untrusted links while authenticated.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
calibrewebPyPI
< 0.6.150.6.15

Affected products

2

Patches

1
785726deee13

Migrated some routes to POST

https://github.com/janeczku/calibre-webOzzieisaacsDec 25, 2021via ghsa
7 files changed · +43 57
  • cps/admin.py+5 5 modified
    @@ -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():
    
  • cps/editbooks.py+6 11 modified
    @@ -26,6 +26,8 @@
     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 .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():
    
  • cps/kobo_auth.py+2 6 modified
    @@ -62,6 +62,7 @@
     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 . 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.
    
  • cps/shelf.py+5 4 modified
    @@ -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")
    
  • cps/static/js/main.js+18 11 modified
    @@ -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) {
    
  • cps/templates/shelf.html+4 18 modified
    @@ -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 @@ <h2>{{title}}</h2>
         {% 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() }}
    
  • cps/web.py+3 2 modified
    @@ -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):
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.