feat(theme-security): add theme safety checks and unify loading via ThemeManager

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-09-01 23:58:38 +05:00
parent 9657ff20d3
commit 2e93073446
6 changed files with 111 additions and 41 deletions

View File

@@ -2,8 +2,10 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
from collections.abc import Callable from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.logger import get_logger 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__) logger = get_logger(__name__)
@@ -23,7 +25,8 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
class GameCardAnimations: class GameCardAnimations:
def __init__(self, game_card, theme=None): def __init__(self, game_card, theme=None):
self.game_card = game_card 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.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None self.scale_anim: QPropertyAnimation | None = None
@@ -232,7 +235,8 @@ class GameCardAnimations:
class DetailPageAnimations: class DetailPageAnimations:
def __init__(self, main_window, theme=None): def __init__(self, main_window, theme=None):
self.main_window = main_window 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 {} 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): def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):

View File

@@ -9,10 +9,9 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
from icoextract import IconExtractor, IconExtractorError from icoextract import IconExtractor, IconExtractorError
from PIL import Image 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.localization import _
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader 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.""" """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): def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager() 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.game_name = game_name
self.target_exe = target_exe # Store the target executable name self.target_exe = target_exe # Store the target executable name
self.setWindowTitle(_("Launching {0}").format(self.game_name)) self.setWindowTitle(_("Launching {0}").format(self.game_name))
@@ -173,8 +172,8 @@ class GameLaunchDialog(QDialog):
class FileExplorer(QDialog): class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager() 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_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter self.file_filter = file_filter # Store the file filter
self.directory_only = directory_only # Store the directory_only flag 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): def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent) super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager() 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.edit_mode = edit_mode
self.original_name = game_name self.original_name = game_name
self.last_exe_path = exe_path # Store last selected exe path self.last_exe_path = exe_path # Store last selected exe path

View File

@@ -2,12 +2,10 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl from PySide6.QtCore import Signal, Property, Qt, QUrl
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable 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.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _ 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.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
@@ -56,7 +54,7 @@ class GameCard(QFrame):
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu) self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager() 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.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config() self.current_theme_name = read_theme_from_config()

View File

@@ -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.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 QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication 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.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
@@ -177,7 +176,8 @@ class FullscreenDialog(QDialog):
self.images = images self.images = images
self.current_index = current_index 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.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
@@ -378,7 +378,8 @@ class ImageCarousel(QGraphicsView):
self.images = images # Список кортежей: (QPixmap, caption) self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = [] self.image_items = []
self._animation = None 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.max_height = 300 # Default height for images
self.init_ui() self.init_ui()
self.create_arrows() self.create_arrows()

View File

@@ -1,9 +1,10 @@
import importlib.util import importlib.util
import os import os
import ast
import re
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from PySide6.QtSvg import QSvgRenderer from PySide6.QtSvg import QSvgRenderer
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -15,6 +16,71 @@ THEMES_DIRS = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") 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(): def list_themes():
""" """
Возвращает список доступных тем (названий папок) из каталогов THEMES_DIRS. Возвращает список доступных тем (названий папок) из каталогов THEMES_DIRS.
@@ -66,7 +132,7 @@ def load_theme_fonts(theme_name):
break break
if not fonts_folder or not os.path.exists(fonts_folder): 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 return
for filename in os.listdir(fonts_folder): for filename in os.listdir(fonts_folder):
@@ -75,9 +141,9 @@ def load_theme_fonts(theme_name):
font_id = QFontDatabase.addApplicationFont(font_path) font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1: if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id) families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Шрифт {filename} успешно загружен: {families}") logger.info(f"Font {filename} successfully loaded: {families}")
else: else:
logger.error(f"Ошибка загрузки шрифта: {filename}") logger.error(f"Error loading font: {filename}")
def load_logo(): def load_logo():
logo_path = None logo_path = None
@@ -90,7 +156,7 @@ def load_logo():
if file_extension == ".svg": if file_extension == ".svg":
renderer = QSvgRenderer(logo_path) renderer = QSvgRenderer(logo_path)
if not renderer.isValid(): if not renderer.isValid():
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}") logger.error(f"Error loading SVG logo: {logo_path}")
return None return None
pixmap = QPixmap(128, 128) pixmap = QPixmap(128, 128)
pixmap.fill(QColor(0, 0, 0, 0)) pixmap.fill(QColor(0, 0, 0, 0))
@@ -109,37 +175,42 @@ class ThemeWrapper:
self.custom_theme = custom_theme self.custom_theme = custom_theme
self.metainfo = metainfo or {} self.metainfo = metainfo or {}
self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
self._default_theme = None # Lazy-loaded default theme
def __getattr__(self, name): def __getattr__(self, name):
if hasattr(self.custom_theme, name): if hasattr(self.custom_theme, name):
return getattr(self.custom_theme, name) return getattr(self.custom_theme, name)
import portprotonqt.themes.standart.styles as default_styles if self._default_theme is None:
return getattr(default_styles, name) self._default_theme = load_theme("standart") # Dynamically load standard theme
return getattr(self._default_theme, name)
def load_theme(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: for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name) theme_folder = os.path.join(themes_dir, theme_name)
styles_file = os.path.join(theme_folder, "styles.py") styles_file = os.path.join(theme_folder, "styles.py")
if os.path.exists(styles_file): 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) spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
continue continue
custom_theme = importlib.util.module_from_spec(spec) custom_theme = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_theme) spec.loader.exec_module(custom_theme)
if theme_name == "standart":
return custom_theme
meta = load_theme_metainfo(theme_name) meta = load_theme_metainfo(theme_name)
wrapper = ThemeWrapper(custom_theme, metainfo=meta) wrapper = ThemeWrapper(custom_theme, metainfo=meta)
wrapper.screenshots = load_theme_screenshots(theme_name) wrapper.screenshots = load_theme_screenshots(theme_name)
return wrapper return wrapper
raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'") raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
class ThemeManager: class ThemeManager:
""" """
@@ -166,12 +237,18 @@ class ThemeManager:
:param theme_name: Имя темы. :param theme_name: Имя темы.
:return: Загруженный модуль темы (или обёртка). :return: Загруженный модуль темы (или обёртка).
""" """
try:
theme_module = load_theme(theme_name) 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) load_theme_fonts(theme_name)
self.current_theme_name = theme_name self.current_theme_name = theme_name
self.current_theme_module = theme_module self.current_theme_module = theme_module
save_theme_to_config(theme_name) save_theme_to_config(theme_name)
logger.info(f"Тема '{theme_name}' успешно применена") logger.info(f"Theme '{theme_name}' successfully applied")
return theme_module return theme_module
def get_icon(self, icon_name, theme_name=None, as_path=False): 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): 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 return QIcon() if not as_path else None
if as_path: if as_path:

View File

@@ -9,7 +9,6 @@ from PySide6.QtGui import QIcon, QAction
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
from portprotonqt.dialogs import GameLaunchDialog from portprotonqt.dialogs import GameLaunchDialog
@@ -31,15 +30,7 @@ class TrayManager:
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
try:
self.theme = self.theme_manager.apply_theme(selected_theme) 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.main_window = main_window self.main_window = main_window
self.tray_icon = QSystemTrayIcon(self.main_window) self.tray_icon = QSystemTrayIcon(self.main_window)