CVE-2026-43985
Description
Tautulli versions before 2.17.1 allow attackers to overwrite admin credentials via a CSRF attack due to missing anti-CSRF tokens and method restrictions.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Tautulli versions before 2.17.1 allow attackers to overwrite admin credentials via a CSRF attack due to missing anti-CSRF tokens and method restrictions.
Vulnerability
Tautulli, a monitoring tool for Plex Media Server, versions prior to 2.17.1 contain a vulnerability in the /configUpdate endpoint. This endpoint, which allows administrators to change local username and password credentials, does not enforce the POST request method and lacks any anti-CSRF token validation. The administrator session cookie is set with SameSite=Lax, permitting cross-site navigation requests [1].
Exploitation
An attacker can exploit this vulnerability by crafting a malicious webpage. When a logged-in administrator visits this page, the attacker's page can submit a cross-site request to the /configUpdate endpoint. This request can overwrite the administrator's username and password with attacker-chosen credentials, allowing the attacker to then log in directly [1].
Impact
Successful exploitation allows an attacker to take over the Tautulli administrative interface by changing the administrator credentials. This grants the attacker full control over the Tautulli instance and any associated data or configurations [1].
Mitigation
Tautulli version 2.17.1, released on 2026-05-04, addresses this vulnerability by adding anti-CSRF tokens and enforcing POST methods for state-changing endpoints, including /configUpdate [2].
AI Insight generated on Jun 4, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1268c45ca2a65Add cherrypy sessions and csrf tokens
11 files changed · +86 −13
data/interfaces/default/base.html+1 −0 modified@@ -14,6 +14,7 @@ <meta name="description" content=""> <meta name="author" content=""> <meta name="referrer" content="no-referrer"> + <meta name="csrf-token" content="${_csrf_token}"> <link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet"> <link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" /> <link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
data/interfaces/default/js/script.js+11 −0 modified@@ -1,3 +1,14 @@ +const csrf_token = $('meta[name="csrf-token"]').attr('content'); +const csrfSafeMethod = method => { + // these HTTP methods do not require CSRF protection + return /^(GET|HEAD|OPTIONS)$/i.test(method); +}; +$(document).ajaxSend(function(event, jqXHR, settings) { + if (!csrfSafeMethod(settings.type) && !settings.crossDomain) { + jqXHR.setRequestHeader("X-CSRF-Token", csrf_token); + } +}); + var p = { name: 'Unknown', version: 'Unknown',
data/interfaces/default/login.html+1 −0 modified@@ -12,6 +12,7 @@ <meta name="description" content=""> <meta name="author" content=""> <meta name="referrer" content="no-referrer"> + <meta name="csrf-token" content="${_csrf_token}"> <link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet"> <link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet"> <link href="${http_root}css/opensans.min.css" rel="stylesheet">
data/interfaces/default/welcome.html+1 −0 modified@@ -13,6 +13,7 @@ <meta name="description" content=""> <meta name="author" content=""> <meta name="referrer" content="no-referrer"> + <meta name="csrf-token" content="${_csrf_token}"> <link href="${http_root}css/bootstrap3/bootstrap.min.css" rel="stylesheet"> <link href="${http_root}css/bootstrap-wizard.css" rel="stylesheet"> <link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
.gitignore+1 −0 modified@@ -28,6 +28,7 @@ backups/* cache/* exports/* newsletters/* +sessions/* *.mmdb version.txt branch.txt
plexpy/config.py+1 −0 modified@@ -184,6 +184,7 @@ def bool_int(value): 'REFRESH_USERS_INTERVAL': (int, 'Monitoring', 12), 'REFRESH_USERS_ON_STARTUP': (int, 'Monitoring', 1), 'SESSION_DB_WRITE_ATTEMPTS': (int, 'Advanced', 5), + 'SESSIONS_DIR': (str, 'General', ''), 'SHOW_ADVANCED_SETTINGS': (int, 'General', 0), 'SYNCHRONOUS_MODE': (str, 'Advanced', 'NORMAL'), 'THEMOVIEDB_APIKEY': (str, 'General', 'e9a6655bae34bf694a0f3e33338dc28e'),
plexpy/__init__.py+5 −0 modified@@ -242,6 +242,8 @@ def initialize(config_file): CONFIG.EXPORT_DIR, os.path.join(DATA_DIR, 'exports'), 'exports') CONFIG.NEWSLETTER_DIR, _ = check_folder_writable( CONFIG.NEWSLETTER_DIR, os.path.join(DATA_DIR, 'newsletters'), 'newsletters') + CONFIG.SESSIONS_DIR, _ = check_folder_writable( + CONFIG.SESSIONS_DIR, os.path.join(DATA_DIR, 'sessions'), 'sessions') # Initialize the database logger.info("Checking if the database upgrades are required...") @@ -553,6 +555,9 @@ def start(): # Cancel processing exports exporter.cancel_exports() + # Clean up stale session locks + webstart.cleanup_session_locks() + if CONFIG.SYSTEM_ANALYTICS: global TRACKER TRACKER = initialize_tracker()
plexpy/session.py+19 −0 modified@@ -15,13 +15,22 @@ # You should have received a copy of the GNU General Public License # along with Tautulli. If not, see <http://www.gnu.org/licenses/>. +import secrets + import cherrypy import plexpy from plexpy import common from plexpy import users +def generate_csrf_token(): + """ + Generates a CSRF token for the current session + """ + return secrets.token_urlsafe(32) + + def get_session_info(): """ Returns the session info for the user session @@ -36,6 +45,16 @@ def get_session_info(): return _session + +def get_session_csrf_token(): + """ + Returns the CSRF token for the current logged in session + """ + if '_csrf_token' not in cherrypy.session: + cherrypy.session['_csrf_token'] = generate_csrf_token() + return cherrypy.session['_csrf_token'] + + def get_session_user(): """ Returns the user_id for the current logged in session
plexpy/webauth.py+14 −0 modified@@ -21,6 +21,7 @@ # Session tool to be loaded. from datetime import datetime, timedelta, timezone +from hmac import compare_digest from urllib.parse import quote, unquote import cherrypy @@ -31,6 +32,7 @@ from plexpy import logger from plexpy.database import MonitorDatabase from plexpy.helpers import timestamp +from plexpy.session import generate_csrf_token from plexpy.users import Users, refresh_users from plexpy.plextv import PlexTV @@ -265,6 +267,15 @@ def check_rate_limit(ip_address): return max(last_timestamp - (timestamp() - plexpy.CONFIG.HTTP_RATE_LIMIT_LOCKOUT_TIME), 0) +def check_csrf_token(): + if not cherrypy.request.path_info.startswith('/api') and cherrypy.request.method in ('POST', 'PUT', 'DELETE'): + session_token = cherrypy.session.get('_csrf_token') + header_token = cherrypy.request.headers.get('X-CSRF-Token') + if not session_token or not header_token or not compare_digest(session_token, header_token): + logger.error("Tautulli WebAuth :: CSRF token validation failed for %s", cherrypy.request.path_info) + raise cherrypy.HTTPError(403) + + # Controller to provide login and logout actions class AuthController(object): @@ -308,6 +319,7 @@ def on_logout(self, username, user_group, jwt_token=None): def get_loginform(self, redirect_uri=''): from plexpy.webserve import serve_template + cherrypy.session['_csrf_token'] = generate_csrf_token() return serve_template(template_name="login.html", title="Login", redirect_uri=unquote(redirect_uri)) @cherrypy.expose @@ -339,6 +351,7 @@ def logout(self, redirect_uri='', *args, **kwargs): cherrypy.response.headers['Set-Cookie'] = jwt_cookie + '=""; max-age=0; path=/' cherrypy.request.login = None + cherrypy.lib.sessions.expire() if redirect_uri: redirect_uri = '?redirect_uri=' + redirect_uri @@ -398,6 +411,7 @@ def signin(self, username=None, password=None, token=None, remember_me='0', admi cherrypy.response.cookie[jwt_cookie]['httponly'] = True cherrypy.response.cookie[jwt_cookie]['samesite'] = 'lax' + cherrypy.session['_csrf_token'] = generate_csrf_token() cherrypy.request.login = payload cherrypy.response.status = 200 return {'status': 'success', 'token': jwt_token, 'uuid': plexpy.CONFIG.PMS_UUID}
plexpy/webserve.py+3 −2 modified@@ -72,7 +72,7 @@ from plexpy import webstart from plexpy.api2 import API2 from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates, build_datatables_json, sanitize_out -from plexpy.session import get_session_info, get_session_user_id, allow_session_user, allow_session_library +from plexpy.session import get_session_info, get_session_csrf_token, get_session_user_id, allow_session_user, allow_session_library from plexpy.webauth import AuthController, requireAuth, member_of, check_auth, get_jwt_token if common.PLATFORM == 'Windows': from plexpy import windows @@ -96,11 +96,12 @@ def serve_template(template_name, **kwargs): cache_param = '?' + (plexpy.CURRENT_VERSION or common.RELEASE) _session = get_session_info() + _csrf_token = get_session_csrf_token() try: template = TEMPLATE_LOOKUP.get_template(template_name) return template.render(http_root=http_root, server_name=server_name, cache_param=cache_param, - _session=_session, **kwargs) + _session=_session, _csrf_token=_csrf_token, **kwargs) except Exception as e: logger.exception("WebUI :: Mako template render error: %s" % e) return mako.exceptions.html_error_template().render()
plexpy/webstart.py+29 −11 modified@@ -18,6 +18,7 @@ import os import ssl import sys +from pathlib import Path import cheroot.errors import cherrypy @@ -150,6 +151,8 @@ def initialize(options): else: plexpy.HTTP_ROOT = options['http_root'] = '/' + cherrypy.tools.csrf = cherrypy.Tool('before_handler', webauth.check_csrf_token, priority=3) + logger.info("Tautulli WebStart :: Thread Pool Size: %d.", plexpy.CONFIG.HTTP_THREAD_POOL) cherrypy.config.update(options_dict) @@ -162,23 +165,33 @@ def initialize(options): 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css', 'text/javascript', 'application/json', 'application/javascript'], + 'tools.sessions.on': True, + 'tools.sessions.name': f'tautulli_session_{plexpy.CONFIG.PMS_UUID}', + 'tools.sessions.storage_class': cherrypy.lib.sessions.FileSession, + 'tools.sessions.storage_path': plexpy.CONFIG.SESSIONS_DIR, + 'tools.sessions.locking': 'early', 'tools.auth.on': plexpy.AUTH_ENABLED, 'tools.auth_basic.on': basic_auth_enabled, 'tools.auth_basic.realm': 'Tautulli web server', 'tools.auth_basic.checkpassword': cherrypy.lib.auth_basic.checkpassword_dict({ - options['http_username']: options['http_password']}) + options['http_username']: options['http_password']}), + 'tools.csrf.on': True, }, '/api': { - 'tools.auth_basic.on': False + 'tools.auth_basic.on': False, + 'tools.sessions.on': False, }, '/status': { - 'tools.auth_basic.on': False + 'tools.auth_basic.on': False, + 'tools.sessions.on': False, }, '/newsletter': { - 'tools.auth_basic.on': False + 'tools.auth_basic.on': False, + 'tools.sessions.on': False, }, '/image': { - 'tools.auth_basic.on': False + 'tools.auth_basic.on': False, + 'tools.sessions.on': False, }, '/interfaces': { 'tools.staticdir.on': True, @@ -189,7 +202,7 @@ def initialize(options): 'tools.expires.on': True, 'tools.expires.secs': 60 * 60 * 24 * 30, # 30 days 'tools.sessions.on': False, - 'tools.auth.on': False + 'tools.auth.on': False, }, '/images': { 'tools.staticdir.on': True, @@ -201,7 +214,7 @@ def initialize(options): 'tools.expires.on': True, 'tools.expires.secs': 60 * 60 * 24 * 30, # 30 days 'tools.sessions.on': False, - 'tools.auth.on': False + 'tools.auth.on': False, }, '/css': { 'tools.staticdir.on': True, @@ -212,7 +225,7 @@ def initialize(options): 'tools.expires.on': True, 'tools.expires.secs': 60 * 60 * 24 * 30, # 30 days 'tools.sessions.on': False, - 'tools.auth.on': False + 'tools.auth.on': False, }, '/fonts': { 'tools.staticdir.on': True, @@ -223,7 +236,7 @@ def initialize(options): 'tools.expires.on': True, 'tools.expires.secs': 60 * 60 * 24 * 30, # 30 days 'tools.sessions.on': False, - 'tools.auth.on': False + 'tools.auth.on': False, }, '/js': { 'tools.staticdir.on': True, @@ -234,7 +247,7 @@ def initialize(options): 'tools.expires.on': True, 'tools.expires.secs': 60 * 60 * 24 * 30, # 30 days 'tools.sessions.on': False, - 'tools.auth.on': False + 'tools.auth.on': False, }, '/favicon.ico': { 'tools.staticfile.on': True, @@ -246,7 +259,7 @@ def initialize(options): 'tools.expires.on': True, 'tools.expires.secs': 60 * 60 * 24 * 30, # 30 days 'tools.sessions.on': False, - 'tools.auth.on': False + 'tools.auth.on': False, } } @@ -300,3 +313,8 @@ def proxy(): # Call original cherrypy proxy tool with the new local cherrypy.lib.cptools.proxy(local=local) + + +def cleanup_session_locks(): + for file in Path(plexpy.CONFIG.SESSIONS_DIR).glob('*.lock'): + file.unlink(missing_ok=True)
Vulnerability mechanics
Root cause
"The `/configUpdate` endpoint does not enforce the POST request method and lacks CSRF protection."
Attack vector
An attacker can exploit this vulnerability by luring a logged-in administrator to a malicious page. This page submits a cross-site request to the `/configUpdate` endpoint, which does not validate the request method or use an anti-CSRF token. The administrator's session cookie, issued with `SameSite=Lax`, permits this top-level navigation request. This allows the attacker to overwrite the administrator's username and password, enabling them to sign in directly and take over the administrative interface [ref_id=1].
Affected code
The vulnerability lies within the `/configUpdate` endpoint in `plexpy/webserve.py`. This endpoint is decorated with `@requireAuth(member_of("admin"))` but does not restrict the request method or include CSRF validation. The code processes `http_password` and `http_username` from the request arguments, hashes the password, and then uses `plexpy.CONFIG.process_kwargs(kwargs)` and `plexpy.CONFIG.write()` to persist these changes [ref_id=1].
What the fix does
Version 2.17.1 addresses the vulnerability by implementing CSRF protection and enforcing the POST method for the `/configUpdate` endpoint. This prevents unauthorized state changes by ensuring that only legitimate, intentionally submitted requests can modify the configuration. The patch ensures that the endpoint requires a valid anti-CSRF token and that the request is made using the POST method, mitigating the risk of cross-site request forgery [patch_id=4822912].
Preconditions
- configTautulli is using the default form and JWT-based authentication mode (HTTP_BASIC_AUTH=0).
- authA legitimate administrator is already logged in to Tautulli in the victim's browser.
Reproduction
The attacker hosts a malicious HTML page containing a form that submits a GET request to `http://victim:8181/configUpdate` with attacker-chosen `http_username` and `http_password` values. When a logged-in administrator visits this page, the form is automatically submitted via JavaScript. This updates the stored administrator credentials. Subsequently, the attacker can log in using the new credentials via a standard POST request to `/auth/signin` [ref_id=1].
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.