File: //opt/cloudlinux/venv/lib/python3.11/site-packages/xray/adviser/wordpress_plugin_manager.py
# -*- coding: utf-8 -*-
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
import logging
import os
import stat
import configparser
import pwd
import json
from xray import gettext as _
class Plugin:
"""
Helper class which hides differences of WordPress plugins behind abstract methods.
"""
NAME = ''
SOURCE_DIR = ''
INFO_FILE_PATH = ''
ZIP_FILE_PATH = ''
PLUGIN_DATA = None
def _get_version(self):
"""Get the plugin version from plugin data"""
if (plugin_data := self._get_data_dict()) is None:
return None
for section in plugin_data:
if 'version' in plugin_data[section]:
return plugin_data[section]['version']
logging.error('Can\'t get the %s plugin version.', self.NAME)
return None
def _read_ini_file(self):
"""Read the ini file"""
config_object = configparser.ConfigParser()
config_object.read(self.INFO_FILE_PATH)
output={s:dict(config_object.items(s)) for s in config_object.sections()}
return output
def _get_data_dict(self):
"""Get the plugin data from ini file"""
if self.PLUGIN_DATA is None:
if os.path.exists(self.INFO_FILE_PATH):
data = self._read_ini_file()
self.PLUGIN_DATA = data
else:
logging.error('Can\'t read the %s plugin data.', self.NAME)
return None
return self.PLUGIN_DATA
def copy_plugin(self, plugin_version: str, dest_dir: str):
"""
Get plugin info if there is a new version and the update is true
copy the plugin archive to the given folder for updating
"""
if not plugin_version or not dest_dir:
logging.error('Can\'t get old plugin version or destination folder for %s.', self.NAME)
return None
# Resolve symlinks and ../ to get the real absolute path
dest_dir = os.path.realpath(dest_dir)
# dest_dir must exist (realpath already resolved any symlinks above)
if not os.path.isdir(dest_dir):
error = _("Destination directory does not exist: {}".format(dest_dir))
logging.error(error)
return format_response(False, error=error)
# Verify dest_dir belongs to the requesting user (via XRAYEXEC_UID)
# to prevent cross-tenant and system directory writes.
# XRAYEXEC_UID is mandatory — refuse to operate without it.
exec_uid = os.getenv('XRAYEXEC_UID')
if exec_uid is None:
error = _("XRAYEXEC_UID is not set, refusing operation")
logging.error(error)
return format_response(False, error=error)
try:
caller_home = pwd.getpwuid(int(exec_uid)).pw_dir
except (KeyError, ValueError):
error = _("Cannot resolve requesting user home directory")
logging.error(error)
return format_response(False, error=error)
caller_home = os.path.realpath(caller_home)
if not dest_dir.startswith(caller_home + '/') and dest_dir != caller_home:
error = _("Destination directory is outside user home: {}".format(dest_dir))
logging.error(error)
return format_response(False, error=error)
new_version = self._get_version()
if new_version is None:
error = _("Cannot determine plugin version for {}".format(self.NAME))
logging.error(error)
return format_response(False, error=error)
filename = self.NAME + '-' + new_version
dest_plugin_path = os.path.join(dest_dir, filename + '.zip')
# If there is a new version and the archive has not yet been copied, then copy it
if (plugin_version != new_version
and plugin_version is not None):
# Use lstat to check the path without following symlinks.
# This avoids the TOCTOU between islink() and exists() —
# lstat tells us in one call whether the path is a symlink,
# a regular file, or doesn't exist.
try:
st = os.lstat(dest_plugin_path)
if stat.S_ISLNK(st.st_mode):
error = _("Plugin path is a symlink, refusing: {}".format(
dest_plugin_path))
logging.error(error)
return format_response(False, error=error)
# Regular file already exists — skip copy
return format_response(True, package=dest_plugin_path)
except FileNotFoundError:
pass # File doesn't exist — proceed to create it
dir_fd = None
fd = None
try:
# Open the destination directory with O_NOFOLLOW to pin it —
# this prevents an attacker from swapping an intermediate path
# component for a symlink between realpath() and file creation.
dir_fd = os.open(
dest_dir,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
)
# Verify the directory fd actually points where we expect
real_dir_path = os.readlink('/proc/self/fd/{}'.format(dir_fd))
if not real_dir_path.startswith(caller_home + '/') and real_dir_path != caller_home:
error = _("Resolved directory path is outside user home: {}".format(
real_dir_path))
logging.error(error)
return format_response(False, error=error)
# Create file relative to the pinned directory fd — immune to
# intermediate symlink swaps since the kernel resolves the
# filename relative to the already-opened directory.
dest_filename = filename + '.zip'
fd = os.open(
dest_filename,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o644,
dir_fd=dir_fd
)
# Copy content through the fd — never uses the path again.
# Loop os.write to handle short writes, since os.write()
# may write fewer bytes than requested.
with open(self.ZIP_FILE_PATH, 'rb') as src:
while True:
chunk = src.read(65536)
if not chunk:
break
mv = memoryview(chunk)
while mv:
written = os.write(fd, mv)
mv = mv[written:]
# Use fchown on the fd to set ownership — immune to TOCTOU
# because it operates on the open file descriptor, not the path.
dir_stat = os.fstat(dir_fd)
os.fchown(fd, dir_stat.st_uid, dir_stat.st_gid)
except FileExistsError:
# File was created between our exists() check and open() — safe to skip
pass
except OSError as e:
error = _("Error happened while copying WordPress {} plugin archive. "
"Error: {}".format(self.NAME, str(e)))
logging.error(error)
# Clean up partial file on error — use dir_fd-relative unlink
# to stay immune to path manipulation
if fd is not None and dir_fd is not None:
try:
os.unlink(dest_filename, dir_fd=dir_fd)
except OSError:
pass
return format_response(False, error=error)
finally:
if fd is not None:
os.close(fd)
if dir_fd is not None:
os.close(dir_fd)
return format_response(True, package=dest_plugin_path)
def get_data(self):
"""Get the plugin data from ini file"""
if not self.PLUGIN_DATA:
self.PLUGIN_DATA = self._get_data_dict()
return format_response(True, data=self.PLUGIN_DATA)
class _AccelerateWp(Plugin):
"""AccelerateWP WordPress plugin manager"""
NAME = 'AccelerateWP'
SOURCE_DIR = '/opt/cloudlinux-site-optimization-module'
INFO_FILE_PATH = SOURCE_DIR + '/clsop.ini'
ZIP_FILE_PATH = SOURCE_DIR + '/clsop.zip'
_PLUGIN_MANAGERS = {"AccelerateWP": _AccelerateWp}
def format_response(is_success, **kwargs):
"""Prepare json response"""
success = 'success' if is_success else 'error'
result = {'result': success}
return json.dumps({**result, **kwargs})
def get_plugin_manager(plugin_name: str):
if plugin_name not in _PLUGIN_MANAGERS:
return None
return _PLUGIN_MANAGERS[plugin_name]()
def get_plugin(plugin_name, plugin_version, dest_dir):
"""
If there is a new version copy the plugin archive
to the given folder for updating
"""
manager = get_plugin_manager(plugin_name)
if manager is None:
error = _("The %s plugin unknown") % plugin_name
logging.error(error)
return format_response(False, error=error)
return manager.copy_plugin(plugin_version=plugin_version, dest_dir=dest_dir)
def get_plugin_data(plugin_name):
"""
Get plugin info
"""
manager = get_plugin_manager(plugin_name)
if manager is None:
error = _("The %s plugin unknown") % plugin_name
logging.error(error)
return format_response(False, error=error)
return manager.get_data()