Security
Headlines
HeadlinesLatestCVEs

Headline

CVE-2021-25965: Added handling for missing flask-wtf dependency · janeczku/calibre-web@50919d4

In Calibre-web, versions 0.6.0 to 0.6.13 are vulnerable to Cross-Site Request Forgery (CSRF). By luring an authenticated user to click on a link, an attacker can create a new user role with admin privileges and attacker-controlled credentials, allowing them to take over the application.

CVE
#sql#csrf#web#js#java

Permalink

Browse files

Added handling for missing flask-wtf dependency

Added CSRF protection (via flask-wtf) Moved upload function to js file Fixed error page in case of csrf failure

  • Loading branch information

Showing with 92 additions and 38 deletions.

  1. +1 −1 cps.py
  2. +17 −0 cps/__init__.py
  3. +13 −8 cps/about.py
  4. +8 −2 cps/kobo.py
  5. +0 −1 cps/static/js/edit_books.js
  6. +20 −2 cps/static/js/main.js
  7. +1 −0 cps/templates/book_edit.html
  8. +1 −0 cps/templates/book_table.html
  9. +1 −0 cps/templates/config_db.html
  10. +1 −0 cps/templates/config_edit.html
  11. +3 −2 cps/templates/config_view_edit.html
  12. +2 −0 cps/templates/detail.html
  13. +8 −5 cps/templates/email_edit.html
  14. +1 −1 cps/templates/http_error.html
  15. +1 −12 cps/templates/layout.html
  16. +1 −0 cps/templates/list.html
  17. +1 −0 cps/templates/login.html
  18. +1 −0 cps/templates/register.html
  19. +1 −0 cps/templates/search_form.html
  20. +1 −0 cps/templates/shelf_edit.html
  21. +1 −0 cps/templates/user_edit.html
  22. +1 −0 cps/templates/user_table.html
  23. +2 −3 cps/web.py
  24. +1 −0 requirements.txt
  25. +4 −1 setup.cfg

@@ -49,7 +49,7 @@

from cps.kobo import kobo, get_kobo_activated

from cps.kobo_auth import kobo_auth

kobo_available = get_kobo_activated()

except ImportError:

except (ImportError, AttributeError): # Catch also error for not installed flask-wtf (missing csrf decorator)

kobo_available = False

try:

@@ -43,6 +43,12 @@

except ImportError:

lxml_present = False

try:

from flask_wtf.csrf import CSRFProtect

wtf_present = True

except ImportError:

wtf_present = False

mimetypes.init()

mimetypes.add_type('application/xhtml+xml’, ‘.xhtml’)

mimetypes.add_type('application/epub+zip’, ‘.epub’)

@@ -75,6 +81,12 @@

lm.anonymous_user = ub.Anonymous

lm.session_protection = ‘strong’

if wtf_present:

csrf = CSRFProtect()

csrf.init_app(app)

else:

csrf = None

ub.init_db(cli.settingspath)

# pylint: disable=no-member

config = config_sql.load_configuration(ub.session)

@@ -105,6 +117,11 @@ def create_app():

log.info(‘*** “lxml” is needed for calibre-web to run. Please install it using pip: “pip install lxml” ***’)

print(‘*** “lxml” is needed for calibre-web to run. Please install it using pip: “pip install lxml” ***’)

sys.exit(6)

if not wtf_present:

log.info(‘*** “flask-wtf” is needed for calibre-web to run. Please install it using pip: “pip install flask-wtf” ***’)

print(‘*** “flask-wtf” is needed for calibre-web to run. Please install it using pip: “pip install flask-wtf” ***’)

sys.exit(7)

app.wsgi_app = ReverseProxied(app.wsgi_app)

# For python2 convert path to unicode

if sys.version_info < (3, 0):

@@ -29,6 +29,10 @@

import babel, pytz, requests, sqlalchemy

import werkzeug, flask, flask_login, flask_principal, jinja2

from flask_babel import gettext as _

try:

from flask_wtf import __version__ as flaskwtf_version

except ImportError:

flaskwtf_version = _(u’not installed’)

from . import db, calibre_db, converter, uploader, server, isoLanguages, constants

from .render_template import render_title_template

@@ -75,6 +79,7 @@

Flask=flask.__version__,

Flask_Login=flask_loginVersion,

Flask_Principal=flask_principal.__version__,

Flask_WTF=flaskwtf_version,

Werkzeug=werkzeug.__version__,

Babel=babel.__version__,

Jinja2=jinja2.__version__,

@@ -84,14 +89,14 @@

SQLite=sqlite3.sqlite_version,

iso639=isoLanguages.__version__,

pytz=pytz.__version__,

Unidecode = unidecode_version,

Scholarly = scholarly_version,

Flask_SimpleLDAP = u’installed’ if bool(services.ldap) else None,

python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,

Goodreads = u’installed’ if bool(services.goodreads_support) else None,

jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None,

flask_dance = flask_danceVersion,

greenlet = greenlet_Version

Unidecode=unidecode_version,

Scholarly=scholarly_version,

Flask_SimpleLDAP=u’installed’ if bool(services.ldap) else None,

python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None,

Goodreads=u’installed’ if bool(services.goodreads_support) else None,

jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None,

flask_dance=flask_danceVersion,

greenlet=greenlet_Version

)

_VERSIONS.update(uploader.get_versions())

@@ -47,7 +47,8 @@

from sqlalchemy.sql import select

import requests

from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub

from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf

from .constants import sqlalchemy_version2

from .helper import get_download_link

from .services import SyncToken as SyncToken

@@ -505,7 +506,7 @@ def get_metadata(book):

return metadata

@csrf.exempt

@kobo.route("/v1/library/tags", methods=["POST", “DELETE”])

@requires_kobo_auth

# Creates a Shelf with the given items, and returns the shelf’s uuid.

@@ -595,6 +596,7 @@ def add_items_to_shelf(items, shelf):

return items_unknown_to_calibre

@csrf.exempt

@kobo.route("/v1/library/tags/<tag_id>/items", methods=[“POST”])

@requires_kobo_auth

def HandleTagAddItem(tag_id):

@@ -624,6 +626,7 @@ def HandleTagAddItem(tag_id):

return make_response('’, 201)

@csrf.exempt

@kobo.route("/v1/library/tags/<tag_id>/items/delete", methods=[“POST”])

@requires_kobo_auth

def HandleTagRemoveItem(tag_id):

@@ -983,6 +986,7 @@ def HandleUnimplementedRequest(dummy=None):

# TODO: Implement the following routes

@csrf.exempt

@kobo.route("/v1/user/loyalty/<dummy>", methods=["GET", “POST”])

@kobo.route("/v1/user/profile", methods=["GET", “POST”])

@kobo.route("/v1/user/wishlist", methods=["GET", “POST”])

@@ -993,6 +997,7 @@ def HandleUserRequest(dummy=None):

return redirect_or_proxy_request()

@csrf.exempt

@kobo.route("/v1/products/<dummy>/prices", methods=["GET", “POST”])

@kobo.route("/v1/products/<dummy>/recommendations", methods=["GET", “POST”])

@kobo.route("/v1/products/<dummy>/nextread", methods=["GET", “POST”])

@@ -1026,6 +1031,7 @@ def make_calibre_web_auth_response():

)

@csrf.exempt

@kobo.route("/v1/auth/device", methods=[“POST”])

@requires_kobo_auth

def HandleAuthRequest():

@@ -23,7 +23,6 @@ if ($(“.tiny_editor”).length) {

$(“.datepicker”).datepicker({

format: "yyyy-mm-dd",

language: language

}).on("change", function () {

// Show localized date over top of the standard YYYY-MM-DD date

var pubDate;

@@ -112,6 +112,14 @@ $(“#btn-upload”).change(function() {

$(“#form-upload”).submit();

});

$(“#form-upload”).uploadprogress({

redirect_url: getPath() + "/", //"{{ url_for(‘web.index’)}}",

uploadedMsg: $(“#form-upload”).data(“message”), //"{{_(‘Upload done, processing, please wait…’)}}",

modalTitle: $(“#form-upload”).data(“title”), //"{{_(‘Uploading…’)}}",

modalFooter: $(“#form-upload”).data(“footer”), //"{{_(‘Close’)}}",

modalTitleFailed: $(“#form-upload”).data(“failed”) //"{{_(‘Error’)}}"

});

$(document).ready(function() {

var inp = $(‘#query’).first()

if (inp.length) {

@@ -223,6 +231,16 @@ $(function() {

var preFilters = $.Callbacks();

$.ajaxPrefilter(preFilters.fire);

// equip all post requests with csrf_token

var csrftoken = $("input[name=’csrf_token’]").val();

$.ajaxSetup({

beforeSend: function(xhr, settings) {

if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {

xhr.setRequestHeader("X-CSRFToken", csrftoken)

}

}

});

function restartTimer() {

$(“#spinner”).addClass(“hidden”);

$(“#RestartDialog”).modal(“hide”);

@@ -576,7 +594,7 @@ $(function() {

method:"post",

dataType: "json",

url: window.location.pathname + "/…/…/ajax/simulatedbchange",

data: {config_calibre_dir: $(“#config_calibre_dir”).val()},

data: {config_calibre_dir: $(“#config_calibre_dir”).val(), csrf_token: $("input[name=’csrf_token’]").val()},

success: function success(data) {

if ( data.change ) {

if ( data.valid ) {

@@ -712,7 +730,7 @@ $(function() {

method:"post",

contentType: "application/json; charset=utf-8",

dataType: "json",

url: window.location.pathname + "/…/ajax/view",

url: getPath() + "/ajax/view",

data: "{\"series\": {\"series_view\": \""+ view +"\"}}",

success: function success() {

location.reload();

@@ -23,6 +23,7 @@

{% if source_formats|length > 0 and conversion_formats|length > 0 %}

<div class="text-center more-stuff"><h4>{{_(‘Convert book format:’)}}</h4>

<form class="padded-bottom" action="{{ url_for('editbook.convert_bookformat’, book_id=book.id) }}" method="post" id="book_convert_frm">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="form-group">

<div class="text-left">

<label class="control-label" for="book_format_from">{{_(‘Convert from:’)}}</label>

@@ -20,6 +20,7 @@

{% endblock %}

{% block body %}

<h2 class="{{page}}">{{_(title)}}</h2>

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="col-xs-12 col-sm-6">

<div class="row form-group">

<div class="btn btn-default disabled" id="merge_books" aria-disabled="true">{{_(‘Merge selected books’)}}</div>

@@ -8,6 +8,7 @@

<div class="discover">

<h2>{{title}}</h2>

<form role="form" method="POST" class="col-md-10 col-lg-6" action="{{ url_for(‘admin.db_configuration’) }}" autocomplete="off">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<label for="config_calibre_dir">{{_(‘Location of Calibre Database’)}}</label>

<div class="form-group required input-group">

<input type="text" class="form-control" id="config_calibre_dir" name="config_calibre_dir" value="{% if config.config_calibre_dir != None %}{{ config.config_calibre_dir }}{% endif %}" autocomplete="off">

@@ -8,6 +8,7 @@

<div class="discover">

<h2>{{title}}</h2>

<form role="form" method="POST" autocomplete="off">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="panel-group col-md-10 col-lg-8">

<div class="panel panel-default">

<div class="panel-heading">

@@ -6,8 +6,9 @@

{% block body %}

<div class="discover">

<h2>{{title}}</h2>

<form role="form" method="POST" autocomplete="off" >

<div class="panel-group class="col-md-10 col-lg-6">

<form role="form" method="POST" autocomplete="off" >

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="panel-group" class="col-md-10 col-lg-6">

<div class="panel panel-default">

<div class="panel-heading">

<h4 class="panel-title">

@@ -214,6 +214,7 @@ <h2 id="title">{{entry.title}}</h2>

<div class="custom_columns">

<p>

<form id="have_read_form" action="{{ url_for('web.toggle_read’, book_id=entry.id)}}" method="POST">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<label class="block-label">

<input id="have_read_cb" data-checked="{{_(‘Mark As Unread’)}}" data-unchecked="{{_(‘Mark As Read’)}}" type="checkbox" {% if have_read %}checked{% endif %} >

<span>{{_(‘Read’)}}</span>

@@ -223,6 +224,7 @@ <h2 id="title">{{entry.title}}</h2>

{% if g.user.check_visibility(32768) %}

<p>

<form id="archived_form" action="{{ url_for('web.toggle_archived’, book_id=entry.id)}}" method="POST">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<label class="block-label">

<input id="archived_cb" data-checked="{{_(‘Restore from archive’)}}" data-unchecked="{{_(‘Add to archive’)}}" type="checkbox" {% if is_archived %}checked{% endif %} >

<span>{{_(‘Archived’)}}</span>

@@ -7,6 +7,7 @@

<div class="discover">

<h1>{{title}}</h1>

<form role="form" class="col-md-10 col-lg-6" method="POST">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

{% if feature_support[‘gmail’] %}

<div class="form-group">

<label for="config_email_type">{{_(‘Choose Server Type’)}}</label>

@@ -72,6 +73,7 @@ <h1>{{title}}</h1>

<div class="col-md-10 col-lg-6">

<h2>{{_('Allowed Domains (Whitelist)')}}</h2>

<form id="domain_add_allow" action="{{ url_for('admin.add_domain’,allow=1)}}" method="POST">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="form-group required">

<label for="domainname_allow">{{_(‘Add Domain’)}}</label>

<input type="text" class="form-control" name="domainname" id="domainname_allow" >

@@ -98,11 +100,12 @@ <h2>{{_('Denied Domains (Blacklist)')}}</h2>

</thead>

</table>

<form id="domain_add_deny" action="{{ url_for('admin.add_domain’,allow=0)}}" method="POST">

<div class="form-group required">

<label for="domainname_deny">{{_(‘Add Domain’)}}</label>

<input type="text" class="form-control" name="domainname" id="domainname_deny" >

</div>

<button id="domain_deny_submit" class="btn btn-default">{{_(‘Add’)}}</button>

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="form-group required">

<label for="domainname_deny">{{_(‘Add Domain’)}}</label>

<input type="text" class="form-control" name="domainname" id="domainname_deny" >

</div>

<button id="domain_deny_submit" class="btn btn-default">{{_(‘Add’)}}</button>

</form>

</div>

@@ -1,5 +1,5 @@

<!DOCTYPE html>

<html class="http-error" lang="{{ g.user.locale }}">

<html class="http-error">

<head>

<title>{{ instance }} | HTTP Error ({{ error_code }})</title>

<meta charset="utf-8">

@@ -61,7 +61,7 @@

{% if g.user.role_upload() or g.user.role_admin()%}

{% if g.allow_upload %}

<li>

<form id="form-upload" class="navbar-form" action="{{ url_for(‘editbook.upload’) }}" method="post" enctype="multipart/form-data">

<form id="form-upload" class="navbar-form" action="{{ url_for(‘editbook.upload’) }}" data-title="{{_(‘Uploading…’)}}" data-footer="{{_(‘Close’)}}" data-failed="{{_(‘Error’)}}" data-message="{{_(‘Upload done, processing, please wait…’)}}" method="post" enctype="multipart/form-data">

<div class="form-group">

<span class="btn btn-default btn-file">{{_(‘Upload’)}}<input id="btn-upload" name="btn-upload"

type="file" accept="{% for format in accept %}.{% if format != '’%}{{format}}{% else %}*{% endif %}{{ ‘,’ if not loop.last }}{% endfor %}" multiple></span>

@@ -200,17 +200,6 @@ <h4 class="modal-title" id="bookDetailsModalLabel">{{_(‘Book Details’)}}</h4>

<script src="{{ url_for(‘static’, filename=’js/libs/plugins.js’) }}"></script>

<script src="{{ url_for(‘static’, filename=’js/libs/jquery.form.min.js’) }}"></script>

<script src="{{ url_for(‘static’, filename=’js/uploadprogress.js’) }}"> </script>

<script type="text/javascript">

$(function() {

$(“#form-upload”).uploadprogress({

redirect_url: "{{ url_for(‘web.index’)}}",

uploadedMsg: "{{_(‘Upload done, processing, please wait…’)}}",

modalTitle: "{{_(‘Uploading…’)}}",

modalFooter: "{{_(‘Close’)}}",

modalTitleFailed: “{{_(‘Error’)}}”

});

});

</script>

<script src="{{ url_for(‘static’, filename=’js/main.js’) }}"></script>

{% if g.current_theme == 1 %}

<script src="{{ url_for(‘static’, filename=’js/libs/jquery.visible.min.js’) }}"></script>

@@ -20,6 +20,7 @@ <h1 class="{{page}}">{{_(title)}}</h1>

</div>

{% if data == “series” %}

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<button class="update-view btn btn-primary" data-target="series_view" id="grid-button" data-view="grid">Grid</button>

{% endif %}

</div>

@@ -4,6 +4,7 @@

<h2 style="margin-top: 0">{{_(‘Login’)}}</h2>

<form method="POST" role="form">

<input type="hidden" name="next" value="{{next_url}}">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="form-group">

<label for="username">{{_(‘Username’)}}</label>

<input type="text" class="form-control" id="username" name="username" placeholder="{{_(‘Username’)}}">

@@ -3,6 +3,7 @@

<div class="well col-sm-6 col-sm-offset-2">

<h2 style="margin-top: 0">{{_(‘Register New Account’)}}</h2>

<form method="POST" role="form">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

{% if not config.config_register_email %}

<div class="form-group required">

<label for="name">{{_(‘Username’)}}</label>

@@ -3,6 +3,7 @@

<h1 class="{{page}}">{{title}}</h1>

<div class="col-md-10 col-lg-6">

<form role="form" id="search" action="{{ url_for(‘web.advanced_search_form’) }}" method="POST">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="form-group">

<label for="book_title">{{_(‘Book Title’)}}</label>

<input type="text" class="form-control" name="book_title" id="book_title" value="">

@@ -3,6 +3,7 @@

<div class="discover">

<h1>{{title}}</h1>

<form role="form" method="POST">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="form-group">

<label for="title">{{_(‘Title’)}}</label>

<input type="text" class="form-control" name="title" id="title" value="{{ shelf.name if shelf.name != None }}">

@@ -3,6 +3,7 @@

<div class="discover">

<h1>{{title}}</h1>

<form role="form" method="POST" autocomplete="off">

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="col-md-10 col-lg-8">

{% if new_user or ( g.user and content.name != “Guest” and g.user.role_admin() ) %}

<div class="form-group required">

@@ -118,6 +118,7 @@

{% endblock %}

{% block body %}

<h2 class="{{page}}">{{_(title)}}</h2>

<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

<div class="col-xs-12 col-sm-12">

<div class="row">

<div class="btn btn-default disabled" id="user_delete_selection" aria-disabled="true">{{_(‘Remove Selections’)}}</div>

CVE: Latest News

CVE-2023-50976: Transactions API Authorization by oleiman · Pull Request #14969 · redpanda-data/redpanda
CVE-2023-6905
CVE-2023-6903
CVE-2023-6904
CVE-2023-3907