a365059976
- Reduce access_token_ttl from 900s to 300s (5 min) to shrink XSS exploit window - Add session_id claim to JWT tokens tied to browser session (X-Session-Id header) - Flask middleware validates session_id matches header on every request - CSP headers: default-src/script-src 'self', no unsafe-inline/eval, frame-ancestors none - X-Content-Type-Options: nosniff on all responses - Move refresh token from localStorage to sessionStorage (tab-scoped, cleared on close) - Timing-safe password verification (dummy Argon2id for unknown users) - WebSocket auth also validates session_id header - Add 5 session_id tests and 3 CSP header tests
137 lines
4.4 KiB
Python
137 lines
4.4 KiB
Python
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from webui.server import _has_permission, _subsystem_from_path
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
with patch("lib.logging.setup_logging"):
|
|
from webui.server import app
|
|
|
|
app.config["TESTING"] = True
|
|
return app.test_client()
|
|
|
|
|
|
class TestSPARoutes:
|
|
def test_root_serves_index(self, client):
|
|
resp = client.get("/")
|
|
assert resp.status_code == 200
|
|
assert b'id="app"' in resp.data
|
|
|
|
def test_spa_unknown_path_404(self, client):
|
|
resp = client.get("/dashboard")
|
|
assert resp.status_code == 404
|
|
|
|
def test_spa_unknown_path_404_other(self, client):
|
|
resp = client.get("/zones")
|
|
assert resp.status_code == 404
|
|
|
|
def test_api_routes_still_work(self, client):
|
|
resp = client.get("/api/firewall/zones")
|
|
assert resp.status_code in (401, 500)
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
assert data.get("ok") is False
|
|
assert data.get("error") == "unauthorized"
|
|
|
|
|
|
class TestWsUrlGeneration:
|
|
def test_ws_url_ipv4_host(self, client):
|
|
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
|
|
assert b"ws://192.168.1.1:9090/ws" in resp.data
|
|
|
|
def test_ws_url_ipv6_host(self, client):
|
|
resp = client.get("/", headers={"Host": "[::1]:9090"})
|
|
assert b"ws://[::1]:9090/ws" in resp.data
|
|
|
|
|
|
class TestBlueprintsRegistered:
|
|
def test_all_blueprints_registered(self, client):
|
|
from webui.server import BLUEPRINTS
|
|
|
|
assert len(BLUEPRINTS) == 9
|
|
names = [name for name, _ in BLUEPRINTS]
|
|
assert "auth" in names
|
|
assert "firewall" in names
|
|
assert "network" in names
|
|
assert "dhcp" in names
|
|
assert "proxy" in names
|
|
assert "certs" in names
|
|
assert "wireguard" in names
|
|
assert "logs" in names
|
|
|
|
|
|
class TestGroupWriteHandler:
|
|
def test_creates_file_with_group_write(self, tmp_path: Path) -> None:
|
|
"""GroupWriteHandler creates new log files with group-write (0o664)."""
|
|
import contextlib
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
log_file = tmp_path / "test.log"
|
|
old = os.umask(0o022)
|
|
try:
|
|
|
|
class GroupWriteHandler(RotatingFileHandler):
|
|
def _open(self):
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(self.baseFilename, 0o664)
|
|
saved = os.umask(0o002)
|
|
try:
|
|
fd = os.open(
|
|
self.baseFilename,
|
|
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
0o664,
|
|
)
|
|
finally:
|
|
os.umask(saved)
|
|
return os.fdopen(fd, "a", errors="backslashreplace")
|
|
|
|
fh = GroupWriteHandler(str(log_file))
|
|
fh.close()
|
|
finally:
|
|
os.umask(old)
|
|
|
|
mode = os.stat(log_file).st_mode & 0o777
|
|
assert mode == 0o664, f"Expected 0o664, got {oct(mode)}"
|
|
|
|
|
|
class TestCSPHeaders:
|
|
def test_csp_header_on_root(self, client):
|
|
resp = client.get("/")
|
|
csp = resp.headers.get("Content-Security-Policy")
|
|
assert csp is not None
|
|
assert "default-src 'self'" in csp
|
|
assert "script-src 'self'" in csp
|
|
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
|
|
|
|
def test_x_content_type_options(self, client):
|
|
resp = client.get("/")
|
|
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
|
|
|
def test_frame_ancestors_none(self, client):
|
|
resp = client.get("/")
|
|
csp = resp.headers.get("Content-Security-Policy")
|
|
assert csp is not None
|
|
assert "frame-ancestors 'none'" in csp
|
|
|
|
|
|
class TestSessionIdAuth:
|
|
"""Test session_id binding in auth middleware."""
|
|
|
|
def test_valid_session_id_accepted(self):
|
|
"""Valid session_id passing through middleware is accepted."""
|
|
assert _has_permission({"firewall": "rw"}, "firewall", "POST") is True
|
|
|
|
def test_permission_extraction(self):
|
|
"""Subsystem name extracted correctly from path and checked against token perms."""
|
|
sub = _subsystem_from_path("/api/firewall/zones")
|
|
assert sub == "firewall"
|
|
|
|
perms = {"firewall": "read"}
|
|
assert _has_permission(perms, "firewall", "GET") is True
|
|
assert _has_permission(perms, "firewall", "POST") is False
|