security: fix 10 vulnerabilities from security audit

- SSRF: block private/internal URLs in image cache fetch
- SSRF: block private/internal URLs in RSS feed fetch (scheme guard)
- SSRF: block private/internal URLs in CalDAV URL setting
- Auth: require login for GET /api/images/<id> (was unauthenticated)
- Auth: restrict Ollama model pull/delete to admin users only
- Info disclosure: remove email from /api/users/search response
- OAuth: skip email-based account linking when email_verified is false
- Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true
- Rate limit: document proxy header requirement; add startup warning
- XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 00:37:13 -04:00
parent 024075329d
commit 00643c778e
15 changed files with 81 additions and 16 deletions
+27
View File
@@ -7,7 +7,9 @@ twice shares one file and one DB row.
"""
import hashlib
import ipaddress
import logging
import socket
from pathlib import Path
from urllib.parse import urlparse
@@ -21,6 +23,27 @@ from fabledassistant.models.image_cache import ImageCache
logger = logging.getLogger(__name__)
def _is_safe_image_url(url: str) -> bool:
"""Return True only if the URL is a public http/https address (SSRF guard)."""
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname
if not host:
return False
if host.lower() in ("localhost", "::1"):
return False
addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
for entry in addr_info:
ip = ipaddress.ip_address(entry[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return False
return True
except Exception:
return False # Block on resolution failure
_ALLOWED_TYPES = {
"image/jpeg",
"image/png",
@@ -59,6 +82,10 @@ async def fetch_and_store_image(
Returns None if the URL is unreachable, not a valid image type, or
exceeds IMAGE_MAX_BYTES.
"""
if not _is_safe_image_url(url):
logger.warning("Blocked image fetch of private/internal URL: %s", url[:80])
return None
url_hash = _url_hash(url)
# Return existing record if already cached
+4
View File
@@ -55,6 +55,10 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
Fetch a feed URL, parse it, and upsert new items into rss_items.
Returns the number of new items stored.
"""
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
logger.warning("Blocked RSS fetch with non-http(s) scheme: %s", url[:80])
return 0
try:
parsed = await _parse_feed(url)
except Exception: