Merge pull request #41430 from helix4u/fix-url-tools-unicode-normalization

fix(tools): percent-encode non-ascii URL components
This commit is contained in:
kshitij
2026-06-07 12:39:30 -07:00
committed by GitHub
5 changed files with 161 additions and 6 deletions
+10
View File
@@ -78,10 +78,12 @@ try:
from tools.url_safety import (
is_safe_url as _is_safe_url,
is_always_blocked_url as _is_always_blocked_url,
normalize_url_for_request as _normalize_url_for_request,
)
except Exception:
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
_is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too
_normalize_url_for_request = lambda url: url # noqa: E731 — best-effort fallback
# Browser-provider ABC + registry — PR #25214 moved the per-vendor providers
# (Browserbase / Browser Use / Firecrawl) out of ``tools/browser_providers/``
# and into ``plugins/browser/<vendor>/``. The dispatcher consults the
@@ -2310,6 +2312,14 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
url = _normalize_url_for_request(url)
normalized_decoded = urllib.parse.unquote(url)
if _PREFIX_RE.search(url) or _PREFIX_RE.search(normalized_decoded):
return json.dumps({
"success": False,
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
# SSRF protection — block private/internal addresses before navigating.
# Skipped for local backends (Camofox, headless Chromium without a cloud
+42 -1
View File
@@ -28,12 +28,53 @@ import logging
import os
import socket
import asyncio
from urllib.parse import urlparse
from urllib.parse import quote, urlparse, urlsplit, urlunsplit
from utils import is_truthy_value
logger = logging.getLogger(__name__)
def normalize_url_for_request(url: str) -> str:
"""Return an ASCII-safe HTTP URL for Hermes-owned URL tools.
Browsers and HTTP clients expect URIs, but users and models often provide
IRIs such as ``https://wttr.in/Köln``. Preserve URL syntax and existing
percent escapes while encoding non-ASCII host/path/query/fragment text.
This is intentionally for URL tool inputs only; arbitrary shell commands
must not be rewritten.
"""
if not isinstance(url, str):
return url
raw = url.strip()
if not raw:
return raw
try:
parsed = urlsplit(raw)
except ValueError:
return raw
if parsed.scheme.lower() not in {"http", "https"}:
return raw
netloc = parsed.netloc
hostname = parsed.hostname
if hostname:
try:
ascii_host = hostname.encode("idna").decode("ascii")
except UnicodeError:
ascii_host = hostname
if ascii_host != hostname:
netloc = netloc.replace(hostname, ascii_host, 1)
path = quote(parsed.path, safe="/%:@!$&'()*+,;=")
query = quote(parsed.query, safe="/%:@!$&'()*+,;=?")
fragment = quote(parsed.fragment, safe="/%:@!$&'()*+,;=?")
return urlunsplit((parsed.scheme, netloc, path, query, fragment))
# Hostnames that should always be blocked regardless of IP resolution
# or any config toggle. These are cloud metadata endpoints that an
# attacker could use to steal instance credentials.
+13 -5
View File
@@ -102,7 +102,7 @@ from tools.tool_backend_helpers import ( # noqa: F401
nous_tool_gateway_unavailable_message,
prefers_gateway,
)
from tools.url_safety import async_is_safe_url
from tools.url_safety import async_is_safe_url, normalize_url_for_request
import sys
logger = logging.getLogger(__name__)
@@ -902,17 +902,25 @@ async def web_extract_tool(
# URL-decode first so percent-encoded secrets (%73k- = sk-) are caught.
from agent.redact import _PREFIX_RE
from urllib.parse import unquote
normalized_urls: List[str] = []
for _url in urls:
if _PREFIX_RE.search(_url) or _PREFIX_RE.search(unquote(_url)):
normalized_url = normalize_url_for_request(_url)
if (
_PREFIX_RE.search(_url)
or _PREFIX_RE.search(unquote(_url))
or _PREFIX_RE.search(normalized_url)
or _PREFIX_RE.search(unquote(normalized_url))
):
return json.dumps({
"success": False,
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
normalized_urls.append(normalized_url)
debug_call_data = {
"parameters": {
"urls": urls,
"urls": normalized_urls,
"format": format,
"use_llm_processing": use_llm_processing,
"model": model,
@@ -928,12 +936,12 @@ async def web_extract_tool(
}
try:
logger.info("Extracting content from %d URL(s)", len(urls))
logger.info("Extracting content from %d URL(s)", len(normalized_urls))
# ── SSRF protection — filter out private/internal URLs before any backend ──
safe_urls = []
ssrf_blocked: List[Dict[str, Any]] = []
for url in urls:
for url in normalized_urls:
if not await async_is_safe_url(url):
ssrf_blocked.append({
"url": url, "title": "", "content": "",