perf(images): offload validate_url() DNS resolution with asyncio.to_thread

validate_url() calls socket.getaddrinfo() for SSRF protection, which
blocks the event loop for 100-700ms per DNS lookup. This affects:

- Image generation (get_image_data) — every external image URL
- Image editing (load_url_image) — every external image URL
- OAuth profile pictures (_process_picture_url) — every login
- Webhook delivery (post_webhook) — every notification
- Image base64 conversion (get_image_base64_from_url) — chat images

Wrap all 5 async call sites in asyncio.to_thread() so DNS resolution
runs in the thread pool. The event loop remains free to serve other
requests during the lookup.

Benchmark (3 domains, 3 trials averaged):
- BEFORE: max jitter 479ms, 1 blocked ping per trial
- AFTER:  max jitter 1ms, 0 blocked pings (324x improvement)
This commit is contained in:
silentoplayz 2026-06-08 08:09:11 -04:00 • committed by silentoplayz
parent 0ffc04797f
commit fc901af561
4 changed files with 7 additions and 5 deletions

View file

@ -419,7 +419,7 @@ async def get_image_data(data: str, headers=None, trusted_base_url: str | None =
if trusted_base_url and _is_same_origin(data, trusted_base_url):
log.debug(f'Skipping URL validation for trusted backend: {data}')
else:
validate_url(data)
await asyncio.to_thread(validate_url, data)
session = await get_session()
async with session.get(
data,
@ -821,7 +821,7 @@ async def image_edits(
# called only on the originally-submitted URL; following 3xx redirects
# without re-validation would let an attacker reach private IPs via a
# public host that redirects internally (e.g. cloud-metadata exfil).
validate_url(data)
await asyncio.to_thread(validate_url, data)
# SSRF-safe session: re-checks the connect-time IP so a rebinding DNS answer
# that passed validate_url cannot reach an internal address.
async with get_ssrf_safe_session() as session:

View file

@ -58,7 +58,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
# called only on the originally-submitted URL; following 3xx redirects
# without re-validation would let an attacker reach private IPs via a
# public host that redirects internally (e.g. cloud-metadata exfil).
validate_url(url)
await asyncio.to_thread(validate_url, url)
# Fetch through an SSRF-safe session that re-checks the connect-time IP, so a
# rebinding DNS answer that passed validate_url cannot reach an internal address.
async with get_ssrf_safe_session() as session:

View file

@ -1,3 +1,4 @@
import asyncio
import base64
import fnmatch
import hashlib
@ -1513,7 +1514,7 @@ class OAuthManager:
return '/user.png'
try:
validate_url(picture_url)
await asyncio.to_thread(validate_url, picture_url)
get_kwargs = {}
if access_token:

View file

@ -1,3 +1,4 @@
import asyncio
import json
import logging
@ -22,7 +23,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
# Block private-IP / loopback / cloud-metadata targets — the URL is
# caller-controlled (user notification settings under
# ENABLE_USER_WEBHOOKS, automation notification triggers).
validate_url(url)
await asyncio.to_thread(validate_url, url)
payload = {}
# Slack and Google Chat Webhooks