Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
244 lines
7.9 KiB
Python
244 lines
7.9 KiB
Python
"""Restricted web view with URL whitelist enforcement for Kiosk-mode."""
|
|
|
|
import fnmatch
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Optional, Union
|
|
|
|
from PySide6.QtCore import QStandardPaths, QUrl
|
|
from PySide6.QtGui import QDesktopServices
|
|
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
|
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CustomWebEnginePage(QWebEnginePage):
|
|
"""Custom page that handles new window requests for downloads."""
|
|
|
|
def acceptNavigationRequest(
|
|
self, url: Union[QUrl, str], nav_type: QWebEnginePage.NavigationType, is_main_frame: bool
|
|
) -> bool:
|
|
"""Handle navigation requests, including download links.
|
|
|
|
Args:
|
|
url: Target URL (QUrl or string)
|
|
nav_type: Type of navigation (link click, form submit, etc.)
|
|
is_main_frame: Whether this is the main frame
|
|
|
|
Returns:
|
|
True to accept navigation, False to reject
|
|
"""
|
|
# Convert to string if QUrl
|
|
url_str = url.toString() if isinstance(url, QUrl) else url
|
|
|
|
# Log all navigation attempts for debugging
|
|
logger.debug(f"Navigation request: {url_str} (type={nav_type}, main_frame={is_main_frame})")
|
|
|
|
# Check if this might be a download (common file extensions)
|
|
download_extensions = [
|
|
".pdf",
|
|
".zip",
|
|
".rar",
|
|
".7z",
|
|
".tar",
|
|
".gz",
|
|
".doc",
|
|
".docx",
|
|
".xls",
|
|
".xlsx",
|
|
".ppt",
|
|
".pptx",
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".bmp",
|
|
".svg",
|
|
".mp4",
|
|
".mp3",
|
|
".avi",
|
|
".mov",
|
|
".wav",
|
|
".exe",
|
|
".msi",
|
|
".dmg",
|
|
".pkg",
|
|
".csv",
|
|
".txt",
|
|
".json",
|
|
".xml",
|
|
]
|
|
|
|
if any(url_str.lower().endswith(ext) for ext in download_extensions):
|
|
logger.debug(f"Detected potential download URL: {url_str}")
|
|
# This will trigger downloadRequested if it's a download
|
|
|
|
return super().acceptNavigationRequest(url, nav_type, is_main_frame)
|
|
|
|
def createWindow(self, window_type: QWebEnginePage.WebWindowType) -> QWebEnginePage:
|
|
"""Handle new window requests (target=_blank, window.open, etc.).
|
|
|
|
Many downloads are triggered via target="_blank" links.
|
|
|
|
Args:
|
|
window_type: Type of window being created
|
|
|
|
Returns:
|
|
New page instance for the window
|
|
"""
|
|
logger.debug(f"New window requested, type: {window_type}")
|
|
|
|
# Create a temporary page to handle the download
|
|
# This page will never be displayed but allows downloads to work
|
|
download_page = QWebEnginePage(self.profile(), self)
|
|
|
|
logger.debug("Created temporary page for download/popup")
|
|
|
|
# Return the temporary page - it will trigger downloadRequested if it's a download
|
|
return download_page
|
|
|
|
|
|
class RestrictedWebEngineView(QWebEngineView):
|
|
"""Web view that enforces URL whitelist for Kiosk-mode security.
|
|
|
|
If allowed_urls is empty, no restrictions are applied.
|
|
If allowed_urls is not empty, only matching URLs are loaded in the view.
|
|
Non-matching URLs open in the system default browser.
|
|
"""
|
|
|
|
def __init__(self, allowed_urls: Optional[List[str]] = None):
|
|
"""Initialize the restricted web view.
|
|
|
|
Args:
|
|
allowed_urls: List of allowed URL patterns (empty = no restriction)
|
|
Patterns support wildcards: *.example.com, localhost, etc.
|
|
"""
|
|
super().__init__()
|
|
self.allowed_urls = allowed_urls or []
|
|
|
|
# Create persistent profile for cookie and session storage
|
|
self.profile = self._create_persistent_profile()
|
|
|
|
# Use custom page for better download handling with persistent profile
|
|
custom_page = CustomWebEnginePage(self.profile, self)
|
|
self.setPage(custom_page)
|
|
|
|
logger.debug(
|
|
"RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile"
|
|
)
|
|
|
|
# Connect to navigation request handler
|
|
self.page().navigationRequested.connect(self._on_navigation_requested)
|
|
|
|
def _create_persistent_profile(self) -> QWebEngineProfile:
|
|
"""Create and configure a persistent web engine profile.
|
|
|
|
This enables persistent cookies and cache storage, allowing
|
|
authentication sessions (e.g., Microsoft login) to persist
|
|
across application restarts.
|
|
|
|
Returns:
|
|
Configured QWebEngineProfile with persistent storage
|
|
"""
|
|
# Get application data directory
|
|
app_data_dir = QStandardPaths.writableLocation(
|
|
QStandardPaths.StandardLocation.AppDataLocation
|
|
)
|
|
|
|
# Create profile directory path
|
|
profile_path = Path(app_data_dir) / "WebEngineProfile"
|
|
profile_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create persistent profile with custom storage location
|
|
# Using "WebDropBridge" as the profile name
|
|
# Note: No parent specified so we control the lifecycle
|
|
profile = QWebEngineProfile("WebDropBridge")
|
|
profile.setPersistentStoragePath(str(profile_path))
|
|
|
|
# Configure persistent cookies (critical for authentication)
|
|
profile.setPersistentCookiesPolicy(
|
|
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
|
)
|
|
|
|
# Enable HTTP cache for better performance
|
|
profile.setHttpCacheType(QWebEngineProfile.HttpCacheType.DiskHttpCache)
|
|
|
|
# Set cache size to 100 MB
|
|
profile.setHttpCacheMaximumSize(100 * 1024 * 1024)
|
|
|
|
logger.debug(f"Created persistent profile at: {profile_path}")
|
|
logger.debug("Cookies policy: ForcePersistentCookies")
|
|
logger.debug("HTTP cache: DiskHttpCache (100 MB)")
|
|
|
|
return profile
|
|
|
|
def _on_navigation_requested(self, request: QWebEngineNavigationRequest) -> None:
|
|
"""Handle navigation requests and enforce URL whitelist.
|
|
|
|
Args:
|
|
request: Navigation request to process
|
|
"""
|
|
url = request.url()
|
|
|
|
# If no restrictions, allow all URLs
|
|
if not self.allowed_urls:
|
|
return
|
|
|
|
# Check if URL matches whitelist
|
|
if self._is_url_allowed(url):
|
|
# Allow the navigation (default behavior)
|
|
return
|
|
|
|
# URL not whitelisted - open in system browser
|
|
request.reject()
|
|
QDesktopServices.openUrl(url)
|
|
|
|
def _is_url_allowed(self, url: QUrl) -> bool:
|
|
"""Check if a URL matches the whitelist.
|
|
|
|
Supports:
|
|
- Exact domain matches: example.com
|
|
- Wildcard patterns: *.example.com
|
|
- Localhost variations: localhost, 127.0.0.1
|
|
- File URLs: file://...
|
|
|
|
Args:
|
|
url: QUrl to check
|
|
|
|
Returns:
|
|
True if URL is allowed, False otherwise
|
|
"""
|
|
url_str = url.toString()
|
|
host = url.host()
|
|
scheme = url.scheme()
|
|
|
|
# Allow file:// URLs (local webapp)
|
|
if scheme == "file":
|
|
return True
|
|
|
|
# If no whitelist, allow all URLs
|
|
if not self.allowed_urls:
|
|
return True
|
|
|
|
# Check against whitelist patterns
|
|
for pattern in self.allowed_urls:
|
|
# Check full URL pattern
|
|
if fnmatch.fnmatch(url_str, pattern):
|
|
return True
|
|
|
|
# Check host pattern (for http/https URLs)
|
|
if scheme in ("http", "https") and host:
|
|
if fnmatch.fnmatch(host, pattern):
|
|
return True
|
|
|
|
# Also check with www prefix variants
|
|
if fnmatch.fnmatch(f"www.{host}", pattern):
|
|
return True
|
|
if fnmatch.fnmatch(host.replace("www.", ""), pattern):
|
|
return True
|
|
|
|
return False
|