From 0d856a3ef5c9b45e2329dbf69c0b22d954214542 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:47:34 +0000 Subject: [PATCH] CodeQL fixes (#763) - Block SSRF in image cover proxy (validate URL scheme and reject private IPs) - Sanitize settings tab name to prevent path traversal --- shelfmark/core/image_cache.py | 32 +++++++++++++++++++++++++++++ shelfmark/core/settings_registry.py | 6 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/shelfmark/core/image_cache.py b/shelfmark/core/image_cache.py index 2ec41cf9..40c8090d 100644 --- a/shelfmark/core/image_cache.py +++ b/shelfmark/core/image_cache.py @@ -1,12 +1,15 @@ """Disk-based image cache with LRU eviction.""" +import ipaddress import json import os +import socket import threading import time from io import BytesIO from pathlib import Path from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse import requests @@ -466,6 +469,32 @@ class ImageCacheService: 'hit_rate': round(hit_rate, 1), } + @staticmethod + def _is_safe_url(url: str) -> bool: + """Check that a URL is safe to fetch (no SSRF to internal resources).""" + try: + parsed = urlparse(url) + except Exception: + return False + + if parsed.scheme not in ('http', 'https'): + return False + + hostname = parsed.hostname + if not hostname: + return False + + try: + resolved = socket.getaddrinfo(hostname, None) + for _, _, _, _, sockaddr in resolved: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + return False + except (socket.gaierror, ValueError): + return False + + return True + def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]: """Fetch an image from URL and cache it. @@ -477,6 +506,9 @@ class ImageCacheService: Tuple of (image_data, content_type) or None on failure """ try: + if not self._is_safe_url(url): + logger.warning(f"Blocked request to disallowed URL: {url}") + return None response = requests.get( url, diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 50c3d7d2..6d85c3a6 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -348,7 +348,11 @@ def _get_config_file_path(tab_name: str) -> Path: # Core settings tabs share the main settings.json file if tab_name in ("general", "search_mode"): return config_dir / "settings.json" - return config_dir / "plugins" / f"{tab_name}.json" + # Sanitize tab_name to prevent path traversal + safe_name = Path(tab_name).name + if not safe_name or safe_name != tab_name: + raise ValueError(f"Invalid tab name: {tab_name}") + return config_dir / "plugins" / f"{safe_name}.json" def _ensure_config_dir(tab_name: str) -> None: