Skip to content

Commit 4fac0c7

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
feat: server-side favicon proxy
1 parent 6d56a66 commit 4fac0c7

3 files changed

Lines changed: 315 additions & 3 deletions

File tree

backend/open_webui/routers/utils.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
from __future__ import annotations
22

3+
import hashlib
4+
import html
5+
import ipaddress
36
import logging
7+
import socket
8+
import time
9+
from collections import OrderedDict
10+
from urllib.parse import urljoin, urlparse
411

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

src/lib/components/chat/Messages/Citations.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { getContext } from 'svelte';
3+
import { WEBUI_API_BASE_URL } from '$lib/constants';
34
import { embed, showControls, showEmbeds } from '$lib/stores';
45
56
import CitationModal from './Citations/CitationModal.svelte';
@@ -175,7 +176,7 @@
175176
<div class="flex -space-x-1 items-center">
176177
{#each urlCitations.slice(0, 3) as citation, idx}
177178
<img
178-
src="https://www.google.com/s2/favicons?sz=32&domain={citation.source.name}"
179+
src={`${WEBUI_API_BASE_URL}/utils/favicon?url=${encodeURIComponent(citation.source.name)}`}
179180
alt="favicon"
180181
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-white dark:bg-gray-900"
181182
on:error={(e) => {

src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script lang="ts">
2+
import { WEBUI_API_BASE_URL } from '$lib/constants';
3+
24
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
35
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
46
import Search from '$lib/components/icons/Search.svelte';
@@ -66,7 +68,7 @@
6668
<div class=" flex justify-center items-center gap-3">
6769
<div class="w-fit">
6870
<img
69-
src="https://www.google.com/s2/favicons?sz=32&domain={item.link}"
71+
src={`${WEBUI_API_BASE_URL}/utils/favicon?url=${encodeURIComponent(item.link)}`}
7072
alt="{item?.title ?? item.link} favicon"
7173
class="size-3.5"
7274
/>
@@ -106,7 +108,7 @@
106108
<div class=" flex justify-center items-center gap-3">
107109
<div class="w-fit">
108110
<img
109-
src="https://www.google.com/s2/favicons?sz=32&domain={url}"
111+
src={`${WEBUI_API_BASE_URL}/utils/favicon?url=${encodeURIComponent(url)}`}
110112
alt="{url} favicon"
111113
class="size-3.5"
112114
/>

0 commit comments

Comments
 (0)