From c23d35db4a67b98281de23e1aaea1d94b4cbad5b Mon Sep 17 00:00:00 2001 From: jvoisin Date: Wed, 15 Apr 2026 22:47:35 +0200 Subject: [PATCH 1/9] Use 128 bits of entropy instead of only 32 in csp/ub.py Remote login tokens are generated from only 4 bytes of randomness (32 bits = ~4 billion possibilities, 8 hex characters). The /ajax/verify_token endpoint at remotelogin.py:98 has no rate limiting. The token is valid for 10 minutes. At even modest request rates (10,000 req/sec), an attacker can test ~6 million tokens during the 10-minute window , which isn't enough to exhaust the full space, sure, but combined with multiple concurrent login sessions (each generating a new token), or if the attacker can trigger the victim to initiate remote login, the attack becomes more feasible. Compare with the Kobo auth token which uses urandom(16) (128 bits). --- cps/ub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cps/ub.py b/cps/ub.py index 20f0ac911..3aa8a9324 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -537,7 +537,7 @@ class RemoteAuthToken(Base): def __init__(self): super().__init__() - self.auth_token = (hexlify(os.urandom(4))).decode('utf-8') + self.auth_token = (hexlify(os.urandom(16))).decode('utf-8') self.expiration = datetime.now() + timedelta(minutes=10) # 10 min from now def __repr__(self): From 42dc36cc10088c2d34359a353fd6d1ac5b11b4b7 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Wed, 15 Apr 2026 23:03:30 +0200 Subject: [PATCH 2/9] Escape comment columns names Custom columns of type comments are rendered with `|safe` (disabling Jinja2 auto-escaping) and no `clean_string` sanitization. Compare with regular book comments which correctly use `{{ entry.comments[0].text|clean_string|safe }}`. Any user with edit permissions can set a custom comment column to `` and it will execute for every user who views the book detail page or the OPDS feed. This is stored XSS with no authentication barrier beyond edit permission. --- cps/templates/detail.html | 2 +- cps/templates/feed.xml | 2 +- cps/templates/listenmp3.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 164ac424d..638b28bf7 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -232,7 +232,7 @@ {% elif c.datatype == 'datetime' %} {{ column.value|formatdate }} {% elif c.datatype == 'comments' %} - {{ column.value|safe }} + {{ column.value|clean_string|safe }} {% elif c.datatype == 'series' %} {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} {% elif c.datatype == 'text' %} diff --git a/cps/templates/feed.xml b/cps/templates/feed.xml index 6627daac4..70ebc14b7 100644 --- a/cps/templates/feed.xml +++ b/cps/templates/feed.xml @@ -94,7 +94,7 @@ {% elif c.datatype == 'datetime' %} {{ column.value|formatdate }} {% elif c.datatype == 'comments' %} - {{ column.value|safe }} + {{ column.value|clean_string|safe }} {% elif c.datatype == 'series' %} {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} {% elif c.datatype == 'text' %} diff --git a/cps/templates/listenmp3.html b/cps/templates/listenmp3.html index 375a871b4..836c80928 100644 --- a/cps/templates/listenmp3.html +++ b/cps/templates/listenmp3.html @@ -134,7 +134,7 @@ {% elif c.datatype == 'datetime' %} {{ column.value|formatdate }} {% elif c.datatype == 'comments' %} - {{column.value|safe}} + {{column.value|clean_string|safe}} {% elif c.datatype == 'series' %} {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} {% elif c.datatype == 'text' %} From 6208d5e2643193af4578835d9716015dc30a86ac Mon Sep 17 00:00:00 2001 From: jvoisin Date: Wed, 15 Apr 2026 23:14:09 +0200 Subject: [PATCH 3/9] Correctly filter results kobo API Multiple Kobo API endpoints use calibre_db.get_book_by_uuid() at db.py:748-749, which performs a raw unfiltered query, meaning that common_filters() isn't applied, so tag-based ACLs, language restrictions, and archived-book filtering are all bypassed. --- cps/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cps/db.py b/cps/db.py index 74b25c891..8ae718355 100644 --- a/cps/db.py +++ b/cps/db.py @@ -746,7 +746,8 @@ class CalibreDB: .filter(self.common_filters(allow_show_archived)).first()) def get_book_by_uuid(self, book_uuid): - return self.session.query(Books).filter(Books.uuid == book_uuid).first() + return self.session.query(Books).filter(Books.uuid == book_uuid). \ + filter(self.common_filters()).first() def get_book_format(self, book_id, file_format): return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first() From 8cff413c4deccc6f3476b5dec6ea55b551f13c5d Mon Sep 17 00:00:00 2001 From: jvoisin Date: Wed, 15 Apr 2026 23:30:49 +0200 Subject: [PATCH 4/9] Clean atributes as well in clean_string `` womp womp --- cps/clean_html.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cps/clean_html.py b/cps/clean_html.py index 3436f43a5..06c3ee8a6 100644 --- a/cps/clean_html.py +++ b/cps/clean_html.py @@ -36,7 +36,12 @@ def clean_string(unsafe_text, book_id=0): if bleach: allowed_tags = list(ALLOWED_TAGS) allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6", "img"]) - safe_text = clean_html(unsafe_text, tags=set(allowed_tags)) + allowed_attributes = { + "*": ["class", "style"], + "a": ["href", "title", "rel"], + "img": ["src", "alt", "title", "width", "height"], + } + safe_text = clean_html(unsafe_text, tags=set(allowed_tags), attributes=allowed_attributes) else: safe_text = clean_html(unsafe_text) except ParserError as e: From 10caf68d8b22677ee89fd6709c3d6859b6c01376 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:53:10 +0000 Subject: [PATCH 5/9] fix manifest reference --- MANIFEST.in | 2 +- pyproject.toml | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index f07c4d835..adb0712e9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ -graft src/calibreweb +graft cps global-exclude __pycache__ global-exclude *.pyc diff --git a/pyproject.toml b/pyproject.toml index 67a7acca7..affab0593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", ] keywords = [ @@ -118,12 +119,14 @@ kobo = [ ] [project.scripts] -cps = "calibreweb:main" +cps = "cps.main:main" [tool.setuptools] include-package-data = true license-files = ["LICENSE"] -[tool.setuptools.dynamic] -version = {attr = "calibreweb.cps.constants.STABLE_VERSION"} +[tool.setuptools.packages.find] +include = ["cps*"] +[tool.setuptools.dynamic] +version = {attr = "cps.constants.STABLE_VERSION"} From 55cedba69368ed5765cd06a2ba2ebb21e8dbe8c7 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:59:55 +0000 Subject: [PATCH 6/9] bump lxml to v6 for python 3.14 --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index affab0593..e36643dbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "tornado>=6.4.2,<6.6", "Wand>=0.4.4,<0.8.0", "unidecode>=0.04.19,<1.5.0", - "lxml>=4.9.1,<5.4.0", + "lxml>=4.9.1,<6.1.0", "flask-wtf>=0.14.2,<1.3.0", "chardet>=3.0.0,<5.3.0", "netifaces-plus>=0.12.0,<0.13.0", diff --git a/requirements.txt b/requirements.txt index 8ac124e2a..d2aef8606 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ SQLAlchemy>=1.3.0,<2.1.0 tornado>=6.4.2,<6.6 Wand>=0.4.4,<0.8.0 unidecode>=0.04.19,<1.5.0 -lxml>=4.9.1,<5.4.0 +lxml>=4.9.1,<6.1.0 flask-wtf>=0.14.2,<1.3.0 chardet>=3.0.0,<5.3.0 netifaces-plus>=0.12.0,<0.13.0 From 888ab4dc8f09c33c59d70474e5548d4075af162a Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sat, 9 May 2026 10:28:12 +0200 Subject: [PATCH 7/9] Update requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d2aef8606..80060e288 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ SQLAlchemy>=1.3.0,<2.1.0 tornado>=6.4.2,<6.6 Wand>=0.4.4,<0.8.0 unidecode>=0.04.19,<1.5.0 -lxml>=4.9.1,<6.1.0 +lxml>=4.9.1,<6.2.0 flask-wtf>=0.14.2,<1.3.0 chardet>=3.0.0,<5.3.0 netifaces-plus>=0.12.0,<0.13.0 From 69f8767be58a7c3abfcddd4b830233a4c80d3c66 Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sun, 17 May 2026 10:21:19 +0200 Subject: [PATCH 8/9] Fixes from testrun --- cps/db.py | 2 +- cps/web.py | 2 +- pyproject.toml | 11 +- test/Calibre-Web TestSummary_Linux.html | 2207 +++-------------------- 4 files changed, 213 insertions(+), 2009 deletions(-) diff --git a/cps/db.py b/cps/db.py index 003c6d5c3..92e6380ed 100644 --- a/cps/db.py +++ b/cps/db.py @@ -1126,7 +1126,7 @@ class CalibreDB: .filter(self.common_filters()) .count()) if no_lang_count: - tags.append([Category(_("None"), None, "none"), no_lang_count]) + tags.append([Category(_("None"), "None", "none"), no_lang_count]) return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order) else: if not languages: diff --git a/cps/web.py b/cps/web.py index 6c3f1f76c..fc5288591 100644 --- a/cps/web.py +++ b/cps/web.py @@ -711,7 +711,7 @@ def render_language_books(page, name, order): lang_name = _("None") except KeyError: abort(404) - if name == "none": + if name.lower() == "none": entries, random, pagination = calibre_db.fill_indexpage(page, 0, db.Books, db.Languages.lang_code == None, diff --git a/pyproject.toml b/pyproject.toml index e36643dbe..55fbe7b3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ description = "Web app for browsing, reading and downloading eBooks stored in a authors = [{name = "@OzzieIsaacs", email = "Ozzie.Fernandez.Isaacs@googlemail.com"}] maintainers = [{name = "@OzzieIsaacs"}] license = "GPL-3.0-or-later" +license-files = ["LICENSE"] classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3", @@ -41,7 +42,7 @@ dependencies = [ "tornado>=6.4.2,<6.6", "Wand>=0.4.4,<0.8.0", "unidecode>=0.04.19,<1.5.0", - "lxml>=4.9.1,<6.1.0", + "lxml>=4.9.1,<6.2.0", "flask-wtf>=0.14.2,<1.3.0", "chardet>=3.0.0,<5.3.0", "netifaces-plus>=0.12.0,<0.13.0", @@ -119,14 +120,10 @@ kobo = [ ] [project.scripts] -cps = "cps.main:main" +cps = "calibreweb.__main__:main" [tool.setuptools] include-package-data = true -license-files = ["LICENSE"] - -[tool.setuptools.packages.find] -include = ["cps*"] [tool.setuptools.dynamic] -version = {attr = "cps.constants.STABLE_VERSION"} +version = {attr = "calibreweb.cps.constants.STABLE_VERSION"} diff --git a/test/Calibre-Web TestSummary_Linux.html b/test/Calibre-Web TestSummary_Linux.html index 7a0eb6695..d1e2a406d 100644 --- a/test/Calibre-Web TestSummary_Linux.html +++ b/test/Calibre-Web TestSummary_Linux.html @@ -37,20 +37,20 @@
-

Start Time: 2026-04-25 16:06:06

+

Start Time: 2026-05-16 12:48:02

-

Stop Time: 2026-04-25 23:02:36

+

Stop Time: 2026-05-16 20:11:29

-

Duration: 5h 43 min

+

Duration: 6h 9 min

@@ -102,15 +102,15 @@ - + TestAnonymous - 21 - 5 - 6 - 10 + 13 + 13 + 0 + 0 0 - Detail + Detail @@ -161,476 +161,74 @@ - +
TestAnonymous - test_guest_change_visibility_language
- -
- ERROR -
- - - - + PASS - - -
TestAnonymous - test_guest_change_visibility_language
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_change_visibility_publisher
- -
- FAIL -
- - - - + PASS - - -
TestAnonymous - test_guest_change_visibility_publisher
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_change_visibility_rated
- -
- FAIL -
- - - - + PASS - - -
TestAnonymous - test_guest_change_visibility_rated
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_change_visibility_rating
- -
- FAIL -
- - - - + PASS - - -
TestAnonymous - test_guest_change_visibility_rating
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_change_visibility_series
- -
- FAIL -
- - - - + PASS - - -
TestAnonymous - test_guest_change_visibility_series
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_random_books_available
- -
- FAIL -
- - - - + PASS - - -
TestAnonymous - test_guest_random_books_available
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_restricted_settings_visibility
- -
- ERROR -
- - - - + PASS - - -
TestAnonymous - test_guest_restricted_settings_visibility
- - -
- ERROR -
- - - - - - - - - +
TestAnonymous - test_guest_visibility_sidebar
- -
- FAIL -
- - - - - - - - - - -
TestAnonymous - test_guest_visibility_sidebar
- - -
- ERROR -
- - - - + PASS @@ -1602,12 +1200,12 @@ AttributeError: 'bool' object has no attribute 'click' - + TestEditBooks 38 - 36 + 37 + 0 0 - 1 1 Detail @@ -1733,32 +1331,11 @@ AttributeError: 'bool' object has no attribute 'click' - +
TestEditBooks - test_edit_language
- -
- ERROR -
- - - - + PASS @@ -2267,12 +1844,12 @@ IndexError: list index out of range - + TestEditBooksList 20 - 12 - 3 - 5 + 20 + 0 + 0 0 Detail @@ -2389,234 +1966,114 @@ IndexError: list index out of range - +
TestEditBooksList - test_bookslist_edit_languages
- -
- FAIL -
- - - - + PASS - +
TestEditBooksList - test_bookslist_edit_publisher
- -
- ERROR -
- - - - + PASS - +
TestEditBooksList - test_bookslist_edit_series
- -
- ERROR -
- - - - + PASS - +
TestEditBooksList - test_bookslist_edit_seriesindex
- -
- ERROR -
- - - - + PASS - +
TestEditBooksList - test_bookslist_edit_title
- -
- ERROR -
- - - - + PASS - +
TestEditBooksList - test_list_visibility
- -
- FAIL -
- - - - + PASS - +
TestEditBooksList - test_restricted_rights
- -
- ERROR -
- - - - + PASS - +
TestEditBooksList - test_search_books_list
+ PASS + + + + + + + TestLoadMetadata + 1 + 0 + 0 + 1 + 0 + + Detail + + + + + + + +
TestLoadMetadata - test_load_metadata
+
- FAIL + ERROR
- From e732b3b614ee034ffd2921e5536ee15c279d8ead Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sun, 24 May 2026 10:19:49 +0200 Subject: [PATCH 9/9] Output message only in production mode --- cps/server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cps/server.py b/cps/server.py index bf53a3d46..afdf4fa65 100644 --- a/cps/server.py +++ b/cps/server.py @@ -41,7 +41,7 @@ except ImportError: VERSION = 'Tornado ' + _version _GEVENT = False -from . import logger +from . import logger, constants log = logger.create() @@ -216,9 +216,10 @@ class WebServer(object): try: sock, output = self._make_gevent_listener() log.info('Starting Gevent server on %s', output) - # Also print to stdout so interactive terminals show a clear success message try: - print(f"Calibre-Web: server started on {output}") + # Also print to stdout so interactive terminals show a clear success message + if constants.APP_MODE not in ['development', 'test']: + print(f"Calibre-Web: server started on {output}") except Exception: print(f"Calibre-Web: error {output}") pass @@ -274,7 +275,8 @@ class WebServer(object): log.info('Starting Tornado server on %s', output) # Also print to stdout so interactive terminals show a clear success message try: - print(f"Calibre-Web: server started on {output}") + if constants.APP_MODE not in ['development', 'test']: + print(f"Calibre-Web: server started on {output}") except Exception: print(f"Calibre-Web: error {output}") pass