fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
delegated data-action handler; remote MDI SVGs parsed and sanitized
(strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent
Bugs
- WebSocket reconnect: close previous socket before opening new, clear
ping interval per-socket, clear reconnectTimeout up-front, retry on
online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip
Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
— cache grew unbounded)
- Progress drag listeners attached only while dragging
Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
_upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
pip install -e . users see the source-of-truth version, not the stale
package-metadata version baked in at install time
UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
callbacks.empty in both en + ru
This commit is contained in:
+95
-9
@@ -63,10 +63,10 @@ async def lifespan(app: FastAPI):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
|
||||
# Log authentication status
|
||||
# Log authentication status — never log full or partial token material.
|
||||
if settings.api_tokens:
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
labels = ", ".join(settings.api_tokens.keys())
|
||||
logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
|
||||
else:
|
||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||
|
||||
@@ -87,6 +87,24 @@ async def lifespan(app: FastAPI):
|
||||
# Store globally so health endpoint can access cached result
|
||||
app.state.update_checker = update_checker
|
||||
|
||||
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
|
||||
# enforced. Runs once at startup and then hourly until shutdown.
|
||||
from .services.thumbnail_service import ThumbnailService
|
||||
|
||||
async def _thumbnail_cleanup_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(ThumbnailService.cleanup_cache)
|
||||
except Exception as e:
|
||||
logger.warning("Thumbnail cache cleanup failed: %s", e)
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
import asyncio
|
||||
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
@@ -109,6 +127,13 @@ async def lifespan(app: FastAPI):
|
||||
if update_checker is not None:
|
||||
await update_checker.stop()
|
||||
|
||||
# Cancel periodic thumbnail cleanup
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop audio visualizer
|
||||
await ws_manager.stop_audio_monitor()
|
||||
if analyzer and analyzer.running:
|
||||
@@ -117,6 +142,13 @@ async def lifespan(app: FastAPI):
|
||||
# Stop WebSocket status monitor
|
||||
await ws_manager.stop_status_monitor()
|
||||
|
||||
# Shut down dedicated thread pools so pending scripts don't leak threads
|
||||
from .routes.callbacks import shutdown_callback_executor
|
||||
from .routes.scripts import shutdown_script_executor
|
||||
|
||||
shutdown_script_executor()
|
||||
shutdown_callback_executor()
|
||||
|
||||
# Clean up platform-specific resources
|
||||
import platform as _platform
|
||||
if _platform.system() == "Windows":
|
||||
@@ -138,16 +170,43 @@ def create_app() -> FastAPI:
|
||||
# Compress responses > 1KB
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
||||
# CORS — restrict to same-origin by default; users that integrate the API
|
||||
# from another origin (e.g. Home Assistant on a different host) can set
|
||||
# cors_origins in config.yaml.
|
||||
cors_origins = settings.cors_origins or [
|
||||
f"http://localhost:{settings.port}",
|
||||
f"http://127.0.0.1:{settings.port}",
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
(
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: blob: https://api.iconify.design; "
|
||||
"connect-src 'self' https://api.iconify.design ws: wss:; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'"
|
||||
),
|
||||
)
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
return response
|
||||
|
||||
# Add token logging middleware
|
||||
@app.middleware("http")
|
||||
async def token_logging_middleware(request: Request, call_next):
|
||||
@@ -247,7 +306,8 @@ def main():
|
||||
if args.generate_config:
|
||||
config_path = generate_default_config()
|
||||
print(f"Configuration file generated at: {config_path}")
|
||||
print("Authentication is disabled by default. Add api_tokens to enable it.")
|
||||
print("A random API token was generated under api_tokens.default.")
|
||||
print("Run `python -m media_server.main --show-token` to view it.")
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
@@ -260,6 +320,32 @@ def main():
|
||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||
return
|
||||
|
||||
# First-run bootstrap: if no config has ever been written, generate one
|
||||
# with a random token instead of starting in the insecure "no-auth" mode.
|
||||
config_path = get_config_dir() / "config.yaml"
|
||||
if not config_path.exists() and not settings.api_tokens:
|
||||
try:
|
||||
generate_default_config(config_path)
|
||||
print(
|
||||
f"\nFirst run: generated default config at {config_path}.\n"
|
||||
"Run --show-token to retrieve the API token, then restart.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(0)
|
||||
except OSError as e:
|
||||
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
|
||||
|
||||
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
|
||||
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
|
||||
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
|
||||
print(
|
||||
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
|
||||
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
|
||||
" or set allow_lan_without_auth: true in config.yaml to override.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Check if port is available before starting
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user