From 2e93073446b94d78955553f927e8f87053975e44 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 1 Sep 2025 23:58:38 +0500 Subject: [PATCH] feat(theme-security): add theme safety checks and unify loading via ThemeManager Signed-off-by: Boris Yumankulov --- portprotonqt/animations.py | 10 +++- portprotonqt/dialogs.py | 9 ++- portprotonqt/game_card.py | 6 +- portprotonqt/image_utils.py | 7 ++- portprotonqt/theme_manager.py | 109 +++++++++++++++++++++++++++++----- portprotonqt/tray_manager.py | 11 +--- 6 files changed, 111 insertions(+), 41 deletions(-) diff --git a/portprotonqt/animations.py b/portprotonqt/animations.py index c1643be..8f70747 100644 --- a/portprotonqt/animations.py +++ b/portprotonqt/animations.py @@ -2,8 +2,10 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect from collections.abc import Callable -import portprotonqt.themes.standart.styles as default_styles from portprotonqt.logger import get_logger +from portprotonqt.config_utils import read_theme_from_config +from portprotonqt.theme_manager import ThemeManager + logger = get_logger(__name__) @@ -23,7 +25,8 @@ class SafeOpacityEffect(QGraphicsOpacityEffect): class GameCardAnimations: def __init__(self, game_card, theme=None): self.game_card = game_card - self.theme = theme if theme is not None else default_styles + self.theme_manager = ThemeManager() + self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.thickness_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None self.scale_anim: QPropertyAnimation | None = None @@ -232,7 +235,8 @@ class GameCardAnimations: class DetailPageAnimations: def __init__(self, main_window, theme=None): self.main_window = main_window - self.theme = theme if theme is not None else default_styles + self.theme_manager = ThemeManager() + self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.animations = main_window._animations if hasattr(main_window, '_animations') else {} def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable): diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 90db975..c9e3da7 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -9,10 +9,9 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer from icoextract import IconExtractor, IconExtractorError from PIL import Image -from portprotonqt.config_utils import get_portproton_location, read_favorite_folders +from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config from portprotonqt.localization import _ from portprotonqt.logger import get_logger -import portprotonqt.themes.standart.styles as default_styles from portprotonqt.theme_manager import ThemeManager from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.downloader import Downloader @@ -94,8 +93,8 @@ class GameLaunchDialog(QDialog): """Modal dialog to indicate game launch progress, similar to Steam's launch dialog.""" def __init__(self, parent=None, game_name=None, theme=None, target_exe=None): super().__init__(parent) - self.theme = theme if theme else default_styles self.theme_manager = ThemeManager() + self.theme = theme if theme else self.theme_manager.apply_theme(read_theme_from_config()) self.game_name = game_name self.target_exe = target_exe # Store the target executable name self.setWindowTitle(_("Launching {0}").format(self.game_name)) @@ -173,8 +172,8 @@ class GameLaunchDialog(QDialog): class FileExplorer(QDialog): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): super().__init__(parent) - self.theme = theme if theme else default_styles self.theme_manager = ThemeManager() + self.theme = theme if theme else self.theme_manager.apply_theme(read_theme_from_config()) self.file_signal = FileSelectedSignal() self.file_filter = file_filter # Store the file filter self.directory_only = directory_only # Store the directory_only flag @@ -590,8 +589,8 @@ class AddGameDialog(QDialog): def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None): super().__init__(parent) from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт - self.theme = theme if theme else default_styles self.theme_manager = ThemeManager() + self.theme = theme if theme else self.theme_manager.apply_theme(read_theme_from_config()) self.edit_mode = edit_mode self.original_name = game_name self.last_exe_path = exe_path # Store last selected exe path diff --git a/portprotonqt/game_card.py b/portprotonqt/game_card.py index 9dce43a..e10e132 100644 --- a/portprotonqt/game_card.py +++ b/portprotonqt/game_card.py @@ -2,12 +2,10 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices from PySide6.QtCore import Signal, Property, Qt, QUrl from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel from collections.abc import Callable -import portprotonqt.themes.standart.styles as default_styles from portprotonqt.image_utils import load_pixmap_async, round_corners from portprotonqt.localization import _ -from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter +from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config from portprotonqt.theme_manager import ThemeManager -from portprotonqt.config_utils import read_theme_from_config from portprotonqt.custom_widgets import ClickableLabel from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.downloader import Downloader @@ -56,7 +54,7 @@ class GameCard(QFrame): self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self.theme_manager = ThemeManager() - self.theme = theme if theme is not None else default_styles + self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.display_filter = read_display_filter() self.current_theme_name = read_theme_from_config() diff --git a/portprotonqt/image_utils.py b/portprotonqt/image_utils.py index f3ed37d..5700b92 100644 --- a/portprotonqt/image_utils.py +++ b/portprotonqt/image_utils.py @@ -3,7 +3,6 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication -import portprotonqt.themes.standart.styles as default_styles from portprotonqt.config_utils import read_theme_from_config from portprotonqt.theme_manager import ThemeManager from portprotonqt.downloader import Downloader @@ -177,7 +176,8 @@ class FullscreenDialog(QDialog): self.images = images self.current_index = current_index - self.theme = theme if theme else default_styles + self.theme_manager = ThemeManager() + self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) @@ -378,7 +378,8 @@ class ImageCarousel(QGraphicsView): self.images = images # Список кортежей: (QPixmap, caption) self.image_items = [] self._animation = None - self.theme = theme if theme else default_styles + self.theme_manager = ThemeManager() + self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.max_height = 300 # Default height for images self.init_ui() self.create_arrows() diff --git a/portprotonqt/theme_manager.py b/portprotonqt/theme_manager.py index 428b069..cde0a4a 100644 --- a/portprotonqt/theme_manager.py +++ b/portprotonqt/theme_manager.py @@ -1,9 +1,10 @@ import importlib.util import os +import ast +import re from portprotonqt.logger import get_logger from PySide6.QtSvg import QSvgRenderer from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter - from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo logger = get_logger(__name__) @@ -15,6 +16,71 @@ THEMES_DIRS = [ os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") ] +# Запрещенные модули и функции +FORBIDDEN_MODULES = { + "os", + "subprocess", + "shutil", + "sys", + "socket", + "ctypes", + "pathlib", + "glob", +} +FORBIDDEN_FUNCTIONS = { + "exec", + "eval", + "open", + "__import__", +} + +FORBIDDEN_PROPERTIES = { + "box-shadow", + "backdrop-filter", + "cursor", + "text-shadow", +} + +def check_theme_safety(theme_file: str) -> bool: + """ + Проверяет файл темы на наличие запрещённых модулей и функций. + Возвращает True, если файл безопасен, иначе False. + """ + has_errors = False + try: + with open(theme_file) as f: + content = f.read() + + # Проверка на запрещённые QSS-свойства + for prop in FORBIDDEN_PROPERTIES: + if re.search(rf"{prop}\s*:", content, re.IGNORECASE): + logger.error(f"Unknown QSS property found '{prop}' in file {theme_file}") + has_errors = True + + # Проверка на опасные импорты и функции + try: + tree = ast.parse(content) + for node in ast.walk(tree): + # Проверка импортов + if isinstance(node, ast.Import | ast.ImportFrom): + for name in node.names: + if name.name in FORBIDDEN_MODULES: + logger.error(f"Forbidden module '{name.name}' found in file {theme_file}") + has_errors = True + # Проверка вызовов функций + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS: + logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}") + has_errors = True + except SyntaxError as e: + logger.error(f"Syntax error in file {theme_file}: {e}") + has_errors = True + except Exception as e: + logger.error(f"Failed to check theme safety for {theme_file}: {e}") + has_errors = True + + return not has_errors + def list_themes(): """ Возвращает список доступных тем (названий папок) из каталогов THEMES_DIRS. @@ -66,7 +132,7 @@ def load_theme_fonts(theme_name): break if not fonts_folder or not os.path.exists(fonts_folder): - logger.error(f"Папка fonts не найдена для темы '{theme_name}'") + logger.error(f"Fonts folder not found for theme '{theme_name}'") return for filename in os.listdir(fonts_folder): @@ -75,9 +141,9 @@ def load_theme_fonts(theme_name): font_id = QFontDatabase.addApplicationFont(font_path) if font_id != -1: families = QFontDatabase.applicationFontFamilies(font_id) - logger.info(f"Шрифт {filename} успешно загружен: {families}") + logger.info(f"Font {filename} successfully loaded: {families}") else: - logger.error(f"Ошибка загрузки шрифта: {filename}") + logger.error(f"Error loading font: {filename}") def load_logo(): logo_path = None @@ -90,7 +156,7 @@ def load_logo(): if file_extension == ".svg": renderer = QSvgRenderer(logo_path) if not renderer.isValid(): - logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}") + logger.error(f"Error loading SVG logo: {logo_path}") return None pixmap = QPixmap(128, 128) pixmap.fill(QColor(0, 0, 0, 0)) @@ -109,37 +175,42 @@ class ThemeWrapper: self.custom_theme = custom_theme self.metainfo = metainfo or {} self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) + self._default_theme = None # Lazy-loaded default theme def __getattr__(self, name): if hasattr(self.custom_theme, name): return getattr(self.custom_theme, name) - import portprotonqt.themes.standart.styles as default_styles - return getattr(default_styles, name) + if self._default_theme is None: + self._default_theme = load_theme("standart") # Dynamically load standard theme + return getattr(self._default_theme, name) def load_theme(theme_name): """ Динамически загружает модуль стилей выбранной темы и метаинформацию. - Если выбрана стандартная тема, импортируется оригинальный styles.py. + Все темы, включая стандартную, проходят проверку безопасности. Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты. """ - if theme_name == "standart": - import portprotonqt.themes.standart.styles as default_styles - return default_styles - for themes_dir in THEMES_DIRS: theme_folder = os.path.join(themes_dir, theme_name) styles_file = os.path.join(theme_folder, "styles.py") if os.path.exists(styles_file): + # Проверяем безопасность темы перед загрузкой + if not check_theme_safety(styles_file): + logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'") + raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions") + spec = importlib.util.spec_from_file_location("theme_styles", styles_file) if spec is None or spec.loader is None: continue custom_theme = importlib.util.module_from_spec(spec) spec.loader.exec_module(custom_theme) + if theme_name == "standart": + return custom_theme meta = load_theme_metainfo(theme_name) wrapper = ThemeWrapper(custom_theme, metainfo=meta) wrapper.screenshots = load_theme_screenshots(theme_name) return wrapper - raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'") + raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'") class ThemeManager: """ @@ -166,12 +237,18 @@ class ThemeManager: :param theme_name: Имя темы. :return: Загруженный модуль темы (или обёртка). """ - theme_module = load_theme(theme_name) + try: + theme_module = load_theme(theme_name) + except FileNotFoundError: + logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'") + theme_module = load_theme("standart") + theme_name = "standart" + save_theme_to_config("standart") load_theme_fonts(theme_name) self.current_theme_name = theme_name self.current_theme_module = theme_module save_theme_to_config(theme_name) - logger.info(f"Тема '{theme_name}' успешно применена") + logger.info(f"Theme '{theme_name}' successfully applied") return theme_module def get_icon(self, icon_name, theme_name=None, as_path=False): @@ -226,7 +303,7 @@ class ThemeManager: # Если иконка всё равно не найдена if not icon_path or not os.path.exists(icon_path): - logger.error(f"Предупреждение: иконка '{icon_name}' не найдена") + logger.error(f"Warning: icon '{icon_name}' not found") return QIcon() if not as_path else None if as_path: diff --git a/portprotonqt/tray_manager.py b/portprotonqt/tray_manager.py index 667741c..855fc16 100644 --- a/portprotonqt/tray_manager.py +++ b/portprotonqt/tray_manager.py @@ -9,7 +9,6 @@ from PySide6.QtGui import QIcon, QAction from PySide6.QtCore import QTimer from portprotonqt.logger import get_logger from portprotonqt.theme_manager import ThemeManager -import portprotonqt.themes.standart.styles as default_styles from portprotonqt.localization import _ from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config from portprotonqt.dialogs import GameLaunchDialog @@ -31,15 +30,7 @@ class TrayManager: self.theme_manager = ThemeManager() selected_theme = read_theme_from_config() self.current_theme_name = selected_theme - try: - self.theme = self.theme_manager.apply_theme(selected_theme) - except FileNotFoundError: - logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'") - self.theme = self.theme_manager.apply_theme("standart") - self.current_theme_name = "standart" - save_theme_to_config("standart") - if not self.theme: - self.theme = default_styles + self.theme = self.theme_manager.apply_theme(selected_theme) self.main_window = main_window self.tray_icon = QSystemTrayIcon(self.main_window)