Cross-Site Request Forgery (CSRF) in janeczku/calibre-web
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.
| Package | Affected versions | Patched versions |
|---|---|---|
calibrewebPyPI | < 0.6.15 | 0.6.15 |
Affected products
2- janeczku/janeczku/calibre-webv5Range: unspecified
Patches
1785726deee13Migrated some routes to POST
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- github.com/advisories/GHSA-wxr6-29pv-ch68ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-4164ghsaADVISORY
- github.com/janeczku/calibre-web/commit/785726deee13b4d56f6c3503dd57c1e3eb7d6f30ghsax_refsource_MISCWEB
- huntr.dev/bounties/2debace1-a0f3-45c1-95fa-9d0512680758ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.