CVE-2021-25964: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: In “Calibre-web” application, v0.6.0 to v0.6.12, are vulnerable to Stored XSS in “Metadata”. An attacker that has access to edit the metadata information, can inject JavaScript payload in the description field. When a victim tries to open the file, XSS will be triggered.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2021-10-04T15:15Z
Repository: https://github.com/janeczku/calibre-web
Language: Python
Files changed: cps/editbooks.py, cps/jinjia.py, cps/templates/detail.html, cps/templates/index.html, cps/ub.py, optional-requirements.txt, requirements.txt
Commit date: 2021-08-27 09:43:32 +0200
Commit message: Added lxml to needed requirements
Improved displaying of series title, book of series, comments and custom comments

Diff: diff --git a/cps/editbooks.py b/cps/editbooks.py
index fbfc4483..afc07513 100644
--- a/cps/editbooks.py
+++ b/cps/editbooks.py
@@ -24,19 +24,18 @@ from __future__ import division, print_function, unicode_literals
 import os
 from datetime import datetime
 import json
 from shutil import copyfile
 from uuid import uuid4
+from lxml.html.clean import clean_html
 
 # Improve this to check if scholarly is available in a global way, like other pythonic libraries
-have_scholar = True
 try:
     from scholarly import scholarly
+    have_scholar = True
 except ImportError:
     have_scholar = False
-    pass
-
 
 from babel import Locale as LC
 from babel.core import UnknownLocaleError
 from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response
 from flask_babel import gettext as _
@@ -55,10 +54,12 @@ try:
     from functools import wraps
 except ImportError:
     pass  # We're not using Python 3
 
 
+
+
 editbook = Blueprint('editbook', __name__)
 log = logger.create()
 
 
 def upload_required(f):
@@ -457,13 +458,15 @@ def edit_book_series_index(series_index, book):
     return modif_date
 
 # Handle book comments/description
 def edit_book_comments(comments, book):
     modif_date = False
+    if comments:
+        comments = clean_html(comments)
     if len(book.comments):
         if book.comments[0].text != comments:
-            book.comments[0].text = comments
+            book.comments[0].text = clean_html(comments)
             modif_date = True
     else:
         if comments:
             book.comments.append(db.Comments(text=comments, book=book.id))
             modif_date = True
@@ -513,10 +516,12 @@ def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
         to_save[cc_string] = None
     elif c.datatype == 'bool':
         to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0
     elif c.datatype == 'comments':
         to_save[cc_string] = Markup(to_save[cc_string]).unescape()
+        if to_save[cc_string]:
+            to_save[cc_string] = clean_html(to_save[cc_string])
     elif c.datatype == 'datetime':
         try:
             to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
         except ValueError:
             to_save[cc_string] = db.Books.DEFAULT_PUBDATE
diff --git a/cps/jinjia.py b/cps/jinjia.py
index 70a6090e..2d18c6f0 100644
--- a/cps/jinjia.py
+++ b/cps/jinjia.py
@@ -26,14 +26,15 @@ from __future__ import division, print_function, unicode_literals
 import datetime
 import mimetypes
 from uuid import uuid4
 
 from babel.dates import format_date
+from flask_babel import gettext as _
 from flask import Blueprint, request, url_for
 from flask_babel import get_locale
 from flask_login import current_user
-
+from markupsafe import escape
 from . import logger
 
 
 jinjia = Blueprint('jinjia', __name__)
 log = logger.create()
@@ -127,10 +128,14 @@ def formatseriesindex_filter(series_index):
                 return series_index
         except ValueError:
             return series_index
     return 0
 
+@jinjia.app_template_filter('escapedlink')
+def escapedlink_filter(url, text):
+    return "<a href='{}'>{}</a>".format(url, escape(text))
+
 @jinjia.app_template_filter('uuidfilter')
 def uuidfilter(var):
     return uuid4()
 
 
diff --git a/cps/templates/detail.html b/cps/templates/detail.html
index fe19cb6f..4a3d8f23 100644
--- a/cps/templates/detail.html
+++ b/cps/templates/detail.html
@@ -118,13 +118,12 @@
             {% endif %}
           {% endfor %}
         </p>
         </div>
       {% endif %}
-
       {% if entry.series|length > 0 %}
-        <p>{{_("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=("<a href='" + url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id) + "'>" + entry.series[0].name + "</a>")|safe) }}</p>
+          <p>{{_("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe)}}</p>
 
       {% endif %}
 
       {% if entry.languages.__len__() > 0 %}
        <div class="languages">
diff --git a/cps/templates/index.html b/cps/templates/index.html
index b11500e4..dabcd8da 100644
--- a/cps/templates/index.html
+++ b/cps/templates/index.html
@@ -61,11 +61,11 @@
     {% endfor %}
   </div>
 </div>
 {% endif %}
 <div class="discover load-more">
-  <h2 class="{{title}}">{{_(title)}}</h2>
+  <h2 class="{{title}}">{{title}}</h2>
     <div class="filterheader hidden-xs hidden-sm">
       <a data-toggle="tooltip" title="{{_('Sort according to book date, newest first')}}" id="new" class="btn btn-primary" href="{{url_for('web.books_list', data=page, book_id=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>
       <a data-toggle="tooltip" title="{{_('Sort according to book date, oldest first')}}" id="old" class="btn btn-primary" href="{{url_for('web.books_list', data=page, book_id=id, sort_param='old')}}"><span class="glyphicon glyphicon-book"></span> <span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a>
       <a data-toggle="tooltip" title="{{_('Sort title in alphabetical order')}}" id="asc" class="btn btn-primary" href="{{url_for('web.books_list', data=page, book_id=id, sort_param='abc')}}"><span class="glyphicon glyphicon-font"></span><span class="glyphicon glyphicon-sort-by-alphabet"></span></a>
       <a data-toggle="tooltip" title="{{_('Sort title in reverse alphabetical order')}}" id="desc" class="btn btn-primary" href="{{url_for('web.books_list', data=page, book_id=id, sort_param='zyx')}}"><span class="glyphicon glyphicon-font"></span><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></a>
diff --git a/cps/ub.py b/cps/ub.py
index 8750b405..63265f7f 100644
--- a/cps/ub.py
+++ b/cps/ub.py
@@ -71,22 +71,22 @@ def store_user_session():
         try:
             if not check_user_session(flask_session.get('_user_id', ""), flask_session.get('_id', "")):
                 user_session = User_Sessions(flask_session.get('_user_id', ""), flask_session.get('_id', ""))
                 session.add(user_session)
                 session.commit()
-                log.info("Login and store session : " + flask_session.get('_id', ""))
+                log.debug("Login and store session : " + flask_session.get('_id', ""))
             else:
-                log.info("Found stored session : " + flask_session.get('_id', ""))
+                log.debug("Found stored session: " + flask_session.get('_id', ""))
         except (exc.OperationalError, exc.InvalidRequestError) as e:
             session.rollback()
             log.exception(e)
     else:
         log.error("No user id in session")
 
 def delete_user_session(user_id, session_key):
     try:
-        log.info("Deleted session_key : " + session_key)
+        log.debug("Deleted session_key: " + session_key)
         session.query(User_Sessions).filter(User_Sessions.user_id==user_id,
                                             User_Sessions.session_key==session_key).delete()
         session.commit()
     except (exc.OperationalError, exc.InvalidRequestError):
         session.rollback()
diff --git a/optional-requirements.txt b/optional-requirements.txt
index 1fa84d50..af068a51 100644
--- a/optional-requirements.txt
+++ b/optional-requirements.txt
@@ -28,11 +28,10 @@ Flask-SimpleLDAP>=1.4.0,<1.5.0
 #oauth
 Flask-Dance>=2.0.0,<5.1.0
 SQLAlchemy-Utils>=0.33.5,<0.38.0
 
 # extracting metadata
-lxml>=3.8.0,<4.7.0
 rarfile>=2.7
 scholarly>=1.2.0, <1.3
 
 # other
 natsort>=2.2.0,<7.2.0
diff --git a/requirements.txt b/requirements.txt
index b29d3b99..e7f67593 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,5 +10,6 @@ pytz>=2016.10
 requests>=2.11.1,<2.25.0
 SQLAlchemy>=1.3.0,<1.5.0
 tornado>=4.1,<6.2
 Wand>=0.4.4,<0.7.0
 unidecode>=0.04.19,<1.3.0
+lxml>=3.8.0,<4.7.0

Vulnerable code: have_scholar = True
    pass

            book.comments[0].text = comments


        <p>{{_("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=("<a href='" + url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id) + "'>" + entry.series[0].name + "</a>")|safe) }}</p>
  <h2 class="{{title}}">{{_(title)}}</h2>
                log.info("Login and store session : " + flask_session.get('_id', ""))
                log.info("Found stored session : " + flask_session.get('_id', ""))
        log.info("Deleted session_key : " + session_key)
lxml>=3.8.0,<4.7.0
Fixed code: from lxml.html.clean import clean_html
    have_scholar = True


    if comments:
        comments = clean_html(comments)
            book.comments[0].text = clean_html(comments)
        if to_save[cc_string]:
            to_save[cc_string] = clean_html(to_save[cc_string])
from flask_babel import gettext as _
from markupsafe import escape
@jinjia.app_template_filter('escapedlink')
def escapedlink_filter(url, text):
    return "<a href='{}'>{}</a>".format(url, escape(text))

          <p>{{_("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe)}}</p>
  <h2 class="{{title}}">{{title}}</h2>
                log.debug("Login and store session : " + flask_session.get('_id', ""))
                log.debug("Found stored session: " + flask_session.get('_id', ""))
        log.debug("Deleted session_key: " + session_key)
lxml>=3.8.0,<4.7.0