VYPR
High severity8.8NVD Advisory· Published Jun 4, 2026· Updated Jun 4, 2026

CVE-2026-43985

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

2
  • Tautulli/Tautullireferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <2.17.1

Patches

1
268c45ca2a65

Add cherrypy sessions and csrf tokens

https://github.com/tautulli/tautulliJonnyWong16Apr 23, 2026Fixed in 2.17.1via llm-release-walk
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

2

News mentions

0

No linked articles in our index yet.