Add URL whitelist enforcement for Kiosk-mode and enhance configuration management
- Introduced `allowed_urls` in configuration to specify whitelisted domains/patterns. - Implemented `RestrictedWebEngineView` to enforce URL restrictions in the web view. - Updated `MainWindow` to utilize the new restricted web view and added navigation toolbar. - Enhanced unit tests for configuration and restricted web view to cover new functionality.
This commit is contained in:
parent
6bef2f6119
commit
86034358b7
6 changed files with 529 additions and 33 deletions
|
|
@ -46,6 +46,7 @@ class TestConfigFromEnv:
|
|||
f"LOG_LEVEL=DEBUG\n"
|
||||
f"LOG_FILE={tmp_path / 'test.log'}\n"
|
||||
f"ALLOWED_ROOTS={root1},{root2}\n"
|
||||
f"ALLOWED_URLS=example.com,*.test.org\n"
|
||||
f"WEBAPP_URL=http://localhost:8000\n"
|
||||
f"WINDOW_WIDTH=1200\n"
|
||||
f"WINDOW_HEIGHT=800\n"
|
||||
|
|
@ -58,6 +59,7 @@ class TestConfigFromEnv:
|
|||
assert config.app_version == "2.0.0"
|
||||
assert config.log_level == "DEBUG"
|
||||
assert config.allowed_roots == [root1.resolve(), root2.resolve()]
|
||||
assert config.allowed_urls == ["example.com", "*.test.org"]
|
||||
assert config.webapp_url == "http://localhost:8000"
|
||||
assert config.window_width == 1200
|
||||
assert config.window_height == 800
|
||||
|
|
@ -146,3 +148,39 @@ class TestConfigValidation:
|
|||
assert len(config.allowed_roots) == 2
|
||||
assert config.allowed_roots[0] == dir1.resolve()
|
||||
assert config.allowed_roots[1] == dir2.resolve()
|
||||
|
||||
def test_allowed_urls_empty(self, tmp_path):
|
||||
"""Test that empty ALLOWED_URLS means no URL restriction."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("ALLOWED_URLS=\n")
|
||||
|
||||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.allowed_urls == []
|
||||
|
||||
def test_allowed_urls_single(self, tmp_path):
|
||||
"""Test loading single allowed URL."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("ALLOWED_URLS=example.com\n")
|
||||
|
||||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.allowed_urls == ["example.com"]
|
||||
|
||||
def test_allowed_urls_multiple(self, tmp_path):
|
||||
"""Test loading multiple allowed URLs."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("ALLOWED_URLS=example.com,*.test.org,localhost\n")
|
||||
|
||||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.allowed_urls == ["example.com", "*.test.org", "localhost"]
|
||||
|
||||
def test_allowed_urls_with_whitespace(self, tmp_path):
|
||||
"""Test that whitespace is trimmed from allowed URLs."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("ALLOWED_URLS= example.com , test.org \n")
|
||||
|
||||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.allowed_urls == ["example.com", "test.org"]
|
||||
|
|
|
|||
179
tests/unit/test_restricted_web_view.py
Normal file
179
tests/unit/test_restricted_web_view.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Unit tests for RestrictedWebEngineView URL filtering."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest
|
||||
|
||||
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
||||
|
||||
|
||||
class TestRestrictedWebEngineView:
|
||||
"""Test URL whitelist enforcement."""
|
||||
|
||||
def test_no_restrictions_empty_list(self, qtbot):
|
||||
"""Test that empty allowed_urls means no restrictions."""
|
||||
view = RestrictedWebEngineView([])
|
||||
|
||||
# Mock navigation request
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://example.com/page")
|
||||
|
||||
# Should not reject any URL
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_not_called()
|
||||
|
||||
def test_no_restrictions_none(self, qtbot):
|
||||
"""Test that None allowed_urls means no restrictions."""
|
||||
view = RestrictedWebEngineView(None)
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://blocked.com/page")
|
||||
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_not_called()
|
||||
|
||||
def test_exact_domain_match(self, qtbot):
|
||||
"""Test exact domain matching."""
|
||||
view = RestrictedWebEngineView(["example.com"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://example.com/page")
|
||||
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_not_called()
|
||||
|
||||
def test_exact_domain_mismatch(self, qtbot):
|
||||
"""Test that mismatched domains are rejected."""
|
||||
view = RestrictedWebEngineView(["example.com"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://other.com/page")
|
||||
|
||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_called_once()
|
||||
|
||||
def test_wildcard_pattern_match(self, qtbot):
|
||||
"""Test wildcard pattern matching."""
|
||||
view = RestrictedWebEngineView(["*.example.com"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://sub.example.com/page")
|
||||
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_not_called()
|
||||
|
||||
def test_wildcard_pattern_mismatch(self, qtbot):
|
||||
"""Test that non-matching wildcard patterns are rejected."""
|
||||
view = RestrictedWebEngineView(["*.example.com"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://example.org/page")
|
||||
|
||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_called_once()
|
||||
|
||||
def test_localhost_allowed(self, qtbot):
|
||||
"""Test that localhost is allowed."""
|
||||
view = RestrictedWebEngineView(["localhost"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("http://localhost:8000/page")
|
||||
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_not_called()
|
||||
|
||||
def test_file_url_always_allowed(self, qtbot):
|
||||
"""Test that file:// URLs are always allowed."""
|
||||
view = RestrictedWebEngineView(["example.com"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("file:///var/www/index.html")
|
||||
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_not_called()
|
||||
|
||||
def test_multiple_allowed_urls(self, qtbot):
|
||||
"""Test multiple allowed URLs."""
|
||||
view = RestrictedWebEngineView(["example.com", "test.org"])
|
||||
|
||||
# First allowed URL
|
||||
request1 = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request1.url = QUrl("https://example.com/page")
|
||||
view._on_navigation_requested(request1)
|
||||
request1.reject.assert_not_called()
|
||||
|
||||
# Second allowed URL
|
||||
request2 = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request2.url = QUrl("https://test.org/page")
|
||||
view._on_navigation_requested(request2)
|
||||
request2.reject.assert_not_called()
|
||||
|
||||
# Non-allowed URL
|
||||
request3 = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request3.url = QUrl("https://blocked.com/page")
|
||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
||||
view._on_navigation_requested(request3)
|
||||
request3.reject.assert_called_once()
|
||||
|
||||
def test_rejected_url_opens_system_browser(self, qtbot):
|
||||
"""Test that rejected URLs open in system browser."""
|
||||
view = RestrictedWebEngineView(["allowed.com"])
|
||||
|
||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||
request.url = QUrl("https://blocked.com/page")
|
||||
|
||||
with patch(
|
||||
"webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl"
|
||||
) as mock_open:
|
||||
view._on_navigation_requested(request)
|
||||
request.reject.assert_called_once()
|
||||
mock_open.assert_called_once_with(request.url)
|
||||
|
||||
|
||||
class TestURLAllowedLogic:
|
||||
"""Test _is_url_allowed method directly."""
|
||||
|
||||
def test_is_url_allowed_empty_list(self, qtbot):
|
||||
"""Test that empty whitelist allows all URLs."""
|
||||
view = RestrictedWebEngineView([])
|
||||
|
||||
assert view._is_url_allowed(QUrl("https://anything.com")) is True
|
||||
|
||||
def test_is_url_allowed_file_scheme(self, qtbot):
|
||||
"""Test that file:// URLs are always allowed."""
|
||||
view = RestrictedWebEngineView(["example.com"])
|
||||
|
||||
assert view._is_url_allowed(QUrl("file:///app/index.html")) is True
|
||||
|
||||
def test_is_url_allowed_exact_match(self, qtbot):
|
||||
"""Test exact domain match."""
|
||||
view = RestrictedWebEngineView(["example.com"])
|
||||
|
||||
assert view._is_url_allowed(QUrl("https://example.com/page")) is True
|
||||
assert view._is_url_allowed(QUrl("https://other.com/page")) is False
|
||||
|
||||
def test_is_url_allowed_with_port(self, qtbot):
|
||||
"""Test domain matching with port number."""
|
||||
view = RestrictedWebEngineView(["localhost"])
|
||||
|
||||
assert view._is_url_allowed(QUrl("http://localhost:8000/page")) is True
|
||||
|
||||
def test_is_url_allowed_wildcard(self, qtbot):
|
||||
"""Test wildcard pattern matching."""
|
||||
view = RestrictedWebEngineView(["*.example.com", "localhost"])
|
||||
|
||||
# Wildcard *.example.com will match sub.example.com
|
||||
assert view._is_url_allowed(QUrl("https://sub.example.com/page")) is True
|
||||
|
||||
# *.example.com will also match example.com (fnmatch behavior)
|
||||
assert view._is_url_allowed(QUrl("https://example.com/page")) is True
|
||||
|
||||
# But not other domains
|
||||
assert view._is_url_allowed(QUrl("https://other.org/page")) is False
|
||||
|
||||
# localhost should work
|
||||
assert view._is_url_allowed(QUrl("http://localhost:3000")) is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue