|
1 | 1 | import black |
| 2 | +import hashlib |
| 3 | +import html |
| 4 | +import ipaddress |
2 | 5 | import logging |
3 | 6 | import markdown |
| 7 | +import socket |
| 8 | +import time |
| 9 | +from collections import OrderedDict |
| 10 | +from urllib.parse import urljoin, urlparse |
| 11 | + |
| 12 | +import aiohttp |
| 13 | +from bs4 import BeautifulSoup |
4 | 14 |
|
5 | 15 | from open_webui.models.chats import ChatTitleMessagesForm |
6 | 16 | from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT |
|
10 | 20 | from starlette.responses import FileResponse |
11 | 21 |
|
12 | 22 |
|
| 23 | +from open_webui.env import REDIS_KEY_PREFIX |
13 | 24 | from open_webui.utils.misc import get_gravatar_url |
14 | 25 | from open_webui.utils.pdf_generator import PDFGenerator |
15 | 26 | from open_webui.utils.auth import get_admin_user, get_verified_user |
@@ -121,3 +132,302 @@ async def download_db(user=Depends(get_admin_user)): |
121 | 132 | media_type='application/octet-stream', |
122 | 133 | filename='webui.db', |
123 | 134 | ) |
| 135 | + |
| 136 | + |
| 137 | +_FAVICON_CACHE: OrderedDict = OrderedDict() |
| 138 | +_FAVICON_CACHE_MAX = 1000 |
| 139 | +_FAVICON_CACHE_TTL = 7 * 24 * 3600 |
| 140 | +_FAVICON_TIMEOUT = aiohttp.ClientTimeout(total=5) |
| 141 | +_MAX_ICON_BYTES = 256 * 1024 # 256 KiB |
| 142 | +_MAX_HTML_BYTES = 250 * 1024 # 250 KiB |
| 143 | +_MAX_REDIRECTS = 3 |
| 144 | +_ALLOWED_ICON_TYPES = { |
| 145 | + 'image/x-icon', |
| 146 | + 'image/vnd.microsoft.icon', |
| 147 | + 'image/png', |
| 148 | + 'image/jpeg', |
| 149 | + 'image/gif', |
| 150 | + 'image/webp', |
| 151 | +} |
| 152 | +_BADGE_PALETTE = [ |
| 153 | + '#e74c3c', '#e67e22', '#2ecc71', '#3498db', |
| 154 | + '#9b59b6', '#1abc9c', '#e91e63', '#f39c12', |
| 155 | +] |
| 156 | + |
| 157 | + |
| 158 | +def _svg_badge(hostname: str) -> bytes: |
| 159 | + if hostname: |
| 160 | + letter = html.escape(hostname[0].upper()) |
| 161 | + idx = hashlib.sha256(hostname.encode()).digest()[0] % len(_BADGE_PALETTE) |
| 162 | + color = _BADGE_PALETTE[idx] |
| 163 | + else: |
| 164 | + letter = '?' |
| 165 | + color = '#95a5a6' |
| 166 | + svg = ( |
| 167 | + f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' |
| 168 | + f'<circle cx="16" cy="16" r="16" fill="{color}"/>' |
| 169 | + f'<text x="16" y="21" font-size="16" font-family="sans-serif" ' |
| 170 | + f'fill="white" text-anchor="middle">{letter}</text>' |
| 171 | + f'</svg>' |
| 172 | + ) |
| 173 | + return svg.encode() |
| 174 | + |
| 175 | + |
| 176 | +def _normalize_hostname(url: str) -> str | None: |
| 177 | + try: |
| 178 | + url = url.strip() |
| 179 | + if '://' not in url: |
| 180 | + url = 'https://' + url |
| 181 | + parsed = urlparse(url) |
| 182 | + hostname = (parsed.hostname or '').lower().rstrip('.') |
| 183 | + if not hostname: |
| 184 | + return None |
| 185 | + try: |
| 186 | + hostname = hostname.encode('idna').decode('ascii') |
| 187 | + except (UnicodeError, UnicodeDecodeError): |
| 188 | + pass |
| 189 | + return hostname or None |
| 190 | + except Exception: |
| 191 | + return None |
| 192 | + |
| 193 | + |
| 194 | +def _is_safe_url(url: str) -> bool: |
| 195 | + """Return True if url has an http/https scheme and a globally routable target.""" |
| 196 | + try: |
| 197 | + parsed = urlparse(url) |
| 198 | + if parsed.scheme not in ('http', 'https'): |
| 199 | + return False |
| 200 | + hostname = parsed.hostname |
| 201 | + if not hostname: |
| 202 | + return False |
| 203 | + try: |
| 204 | + if not ipaddress.ip_address(hostname).is_global: # raw IP |
| 205 | + return False |
| 206 | + except ValueError: |
| 207 | + pass # hostname; _SafeResolver handles it |
| 208 | + return True |
| 209 | + except Exception: |
| 210 | + return False |
| 211 | + |
| 212 | + |
| 213 | +class _SafeResolver(aiohttp.ThreadedResolver): |
| 214 | + async def resolve(self, hostname, port=0, family=socket.AF_UNSPEC): |
| 215 | + results = await super().resolve(hostname, port, family) |
| 216 | + for r in results: |
| 217 | + if not ipaddress.ip_address(r['host']).is_global: |
| 218 | + raise OSError(f'non-global IP: {r["host"]}') |
| 219 | + return results |
| 220 | + |
| 221 | + |
| 222 | +async def _fetch_bounded( |
| 223 | + session: aiohttp.ClientSession, |
| 224 | + url: str, |
| 225 | + max_bytes: int, |
| 226 | + allowed_types: set[str] | None, |
| 227 | +) -> tuple[bytes, str] | None: |
| 228 | + """GET with capped size and per-hop SSRF validation.""" |
| 229 | + current = url |
| 230 | + for _ in range(_MAX_REDIRECTS + 1): |
| 231 | + try: |
| 232 | + async with session.get( |
| 233 | + current, allow_redirects=False, timeout=_FAVICON_TIMEOUT |
| 234 | + ) as resp: |
| 235 | + if resp.status in (301, 302, 303, 307, 308): |
| 236 | + location = resp.headers.get('Location', '') |
| 237 | + if not location: |
| 238 | + return None |
| 239 | + next_url = urljoin(current, location) |
| 240 | + if not _is_safe_url(next_url): |
| 241 | + return None |
| 242 | + current = next_url |
| 243 | + continue |
| 244 | + |
| 245 | + if resp.status != 200: |
| 246 | + return None |
| 247 | + |
| 248 | + ct = resp.headers.get('Content-Type', '').split(';')[0].strip().lower() |
| 249 | + if allowed_types is not None and ct not in allowed_types: |
| 250 | + return None |
| 251 | + |
| 252 | + data = b'' |
| 253 | + async for chunk in resp.content.iter_chunked(8192): |
| 254 | + data += chunk |
| 255 | + if len(data) > max_bytes: |
| 256 | + return None |
| 257 | + return data, ct |
| 258 | + except Exception: |
| 259 | + return None |
| 260 | + return None |
| 261 | + |
| 262 | + |
| 263 | +async def _discover_favicon(hostname: str) -> tuple[bytes, str] | None: |
| 264 | + # aiohttp bypasses the resolver for raw numeric IPs (is_ip_address() short-circuits |
| 265 | + # _resolve_host). This is the sole guard for that path. |
| 266 | + try: |
| 267 | + if not ipaddress.ip_address(hostname).is_global: |
| 268 | + return None |
| 269 | + except ValueError: |
| 270 | + pass # hostname; _SafeResolver handles it |
| 271 | + |
| 272 | + connector = aiohttp.TCPConnector(resolver=_SafeResolver(), use_dns_cache=False) |
| 273 | + headers = {'User-Agent': 'Mozilla/5.0 (compatible; open-webui/favicon-proxy)'} |
| 274 | + async with aiohttp.ClientSession(connector=connector, headers=headers) as session: |
| 275 | + # try /favicon.ico |
| 276 | + result = await _fetch_bounded( |
| 277 | + session, |
| 278 | + f'https://{hostname}/favicon.ico', |
| 279 | + _MAX_ICON_BYTES, |
| 280 | + _ALLOWED_ICON_TYPES, |
| 281 | + ) |
| 282 | + if result: |
| 283 | + return result |
| 284 | + |
| 285 | + # fall back to HTML <link> discovery |
| 286 | + html_result = await _fetch_bounded( |
| 287 | + session, |
| 288 | + f'https://{hostname}/', |
| 289 | + _MAX_HTML_BYTES, |
| 290 | + {'text/html'}, |
| 291 | + ) |
| 292 | + if not html_result: |
| 293 | + return None |
| 294 | + |
| 295 | + html_bytes, _ = html_result |
| 296 | + try: |
| 297 | + soup = BeautifulSoup(html_bytes, 'html.parser') |
| 298 | + except Exception: |
| 299 | + return None |
| 300 | + |
| 301 | + candidates: list[tuple[int, int, str]] = [] |
| 302 | + for tag in soup.find_all('link'): |
| 303 | + rel = tag.get('rel') or [] |
| 304 | + if isinstance(rel, str): |
| 305 | + rel = [rel] |
| 306 | + rel_lower = [r.lower() for r in rel] |
| 307 | + |
| 308 | + href = tag.get('href') or '' |
| 309 | + if not href or href.startswith('data:'): |
| 310 | + continue |
| 311 | + tag_type = (tag.get('type') or '').lower() |
| 312 | + if 'svg' in tag_type: |
| 313 | + continue |
| 314 | + |
| 315 | + if 'apple-touch-icon' in rel_lower and 'precomposed' not in rel_lower: |
| 316 | + priority, size = 0, 180 |
| 317 | + elif 'icon' in rel_lower: |
| 318 | + priority = 1 |
| 319 | + size = 0 |
| 320 | + sizes_str = (tag.get('sizes') or '').strip() |
| 321 | + if sizes_str and sizes_str.lower() != 'any': |
| 322 | + try: |
| 323 | + size = max( |
| 324 | + int(p.lower().split('x')[0]) |
| 325 | + for p in sizes_str.split() |
| 326 | + if 'x' in p.lower() |
| 327 | + ) |
| 328 | + except Exception: |
| 329 | + size = 0 |
| 330 | + elif 'shortcut' in rel_lower: |
| 331 | + priority, size = 2, 0 |
| 332 | + elif 'apple-touch-icon' in rel_lower: # precomposed |
| 333 | + priority, size = 3, 0 |
| 334 | + else: |
| 335 | + continue |
| 336 | + |
| 337 | + full_href = urljoin(f'https://{hostname}/', href) |
| 338 | + candidates.append((priority, -size, full_href)) |
| 339 | + |
| 340 | + candidates.sort(key=lambda x: (x[0], x[1])) |
| 341 | + |
| 342 | + for _, _, href in candidates: |
| 343 | + if not _is_safe_url(href): |
| 344 | + continue |
| 345 | + icon_result = await _fetch_bounded( |
| 346 | + session, href, _MAX_ICON_BYTES, _ALLOWED_ICON_TYPES |
| 347 | + ) |
| 348 | + if icon_result: |
| 349 | + return icon_result |
| 350 | + |
| 351 | + return None |
| 352 | + |
| 353 | + |
| 354 | +def _cache_get(hostname: str) -> tuple[bytes, str] | None: |
| 355 | + entry = _FAVICON_CACHE.get(hostname) |
| 356 | + if entry is None: |
| 357 | + return None |
| 358 | + content, ct, expires_at = entry |
| 359 | + if time.monotonic() > expires_at: |
| 360 | + del _FAVICON_CACHE[hostname] |
| 361 | + return None |
| 362 | + _FAVICON_CACHE.move_to_end(hostname) |
| 363 | + return content, ct |
| 364 | + |
| 365 | + |
| 366 | +def _cache_set(hostname: str, content: bytes, ct: str) -> None: |
| 367 | + if hostname in _FAVICON_CACHE: |
| 368 | + _FAVICON_CACHE.move_to_end(hostname) |
| 369 | + _FAVICON_CACHE[hostname] = (content, ct, time.monotonic() + _FAVICON_CACHE_TTL) |
| 370 | + while len(_FAVICON_CACHE) > _FAVICON_CACHE_MAX: |
| 371 | + _FAVICON_CACHE.popitem(last=False) |
| 372 | + |
| 373 | + |
| 374 | +_FAVICON_RESPONSE_HEADERS = { |
| 375 | + 'Cache-Control': 'public, max-age=604800', |
| 376 | + 'X-Content-Type-Options': 'nosniff', |
| 377 | +} |
| 378 | + |
| 379 | + |
| 380 | +async def _get_cached_favicon(redis, hostname: str) -> tuple[bytes, str] | None: |
| 381 | + """Return cached (content, ct) from Redis or in-memory, whichever is configured.""" |
| 382 | + if redis is not None: |
| 383 | + try: |
| 384 | + raw = await redis.get(f'{REDIS_KEY_PREFIX}:favicon:{hostname}') |
| 385 | + if raw: |
| 386 | + ct_bytes, _, content = raw.partition(b'\n') |
| 387 | + return content, ct_bytes.decode() |
| 388 | + except Exception: |
| 389 | + pass |
| 390 | + return None |
| 391 | + return _cache_get(hostname) |
| 392 | + |
| 393 | + |
| 394 | +async def _set_cached_favicon(redis, hostname: str, content: bytes, ct: str) -> None: |
| 395 | + """Write (content, ct) to Redis (with TTL) or in-memory, whichever is configured.""" |
| 396 | + if redis is not None: |
| 397 | + try: |
| 398 | + await redis.set( |
| 399 | + f'{REDIS_KEY_PREFIX}:favicon:{hostname}', |
| 400 | + ct.encode() + b'\n' + content, |
| 401 | + ex=_FAVICON_CACHE_TTL, |
| 402 | + ) |
| 403 | + except Exception: |
| 404 | + pass |
| 405 | + else: |
| 406 | + _cache_set(hostname, content, ct) |
| 407 | + |
| 408 | + |
| 409 | +@router.get('/favicon') |
| 410 | +async def get_favicon(request: Request, url: str = '', user=Depends(get_verified_user)): |
| 411 | + """Favicon proxy; always returns a response.""" |
| 412 | + hostname = _normalize_hostname(url) |
| 413 | + redis = request.app.state.redis |
| 414 | + |
| 415 | + if hostname: |
| 416 | + cached = await _get_cached_favicon(redis, hostname) |
| 417 | + if cached: |
| 418 | + content, ct = cached |
| 419 | + return Response(content=content, media_type=ct, headers=_FAVICON_RESPONSE_HEADERS) |
| 420 | + |
| 421 | + icon: tuple[bytes, str] | None = None |
| 422 | + if hostname: |
| 423 | + try: |
| 424 | + icon = await _discover_favicon(hostname) |
| 425 | + except Exception: |
| 426 | + pass |
| 427 | + |
| 428 | + content, ct = icon if icon else (_svg_badge(hostname or ''), 'image/svg+xml') |
| 429 | + |
| 430 | + if hostname: |
| 431 | + await _set_cached_favicon(redis, hostname, content, ct) |
| 432 | + |
| 433 | + return Response(content=content, media_type=ct, headers=_FAVICON_RESPONSE_HEADERS) |
0 commit comments