🔄 synced local 'skyvern/' with remote 'skyvern/'

This commit is contained in:
Shuchang Zheng 2026-07-07 23:09:17 +00:00
parent 632b8ec501
commit 3c4b36a841
9 changed files with 469 additions and 114 deletions

View file

@ -167,6 +167,7 @@ from skyvern.webeye.actions.parse_actions import (
)
from skyvern.webeye.actions.responses import ActionResult, ActionSuccess
from skyvern.webeye.browser_state import BrowserState
from skyvern.webeye.cdp_download_interceptor import download_filename_from_suffix
from skyvern.webeye.scraper.scraped_page import ElementTreeFormat, ScrapedPage
from skyvern.webeye.utils.page import SkyvernFrame, build_open_tabs_context
@ -518,8 +519,21 @@ class ForgeAgent:
continue
if download_suffix:
final_file_name = download_suffix
elif randomize_if_missing:
# local_file_name is a bare basename for session (s3/gs) files but an absolute path for
# run-dir files; compare on basenames so a file already named by download_suffix is not
# treated as its own collision and bumped to ``<name>_1``.
local_basename = Path(local_file_name).name
existing_names = {
Path(f).name
for f in list_files_in_directory(workflow_download_directory)
if Path(f).name != local_basename
}
desired_name = download_filename_from_suffix(download_suffix, file_extension, existing_names)
if local_basename != desired_name:
rename_file(os.path.join(workflow_download_directory, local_file_name), desired_name)
continue
if randomize_if_missing:
random_file_id = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
final_file_name = f"download-{datetime.now().strftime('%Y%m%d%H%M%S%f')}-{random_file_id}"
else:
@ -758,6 +772,7 @@ class ForgeAgent:
context.task_id = task.task_id
context.navigation_goal = task.navigation_goal
context.navigation_payload = task.navigation_payload
context.download_suffix = task_block.download_suffix if task_block else None
# do not need to do complete verification when it's a CUA task
# 1. CUA executes only one action step by step -- it's pretty less likely to have a hallucination for completion or forget to return a complete

View file

@ -6,6 +6,7 @@ import re
import shutil
import tempfile
import zipfile
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import parse_qsl, unquote, urlparse
@ -17,9 +18,20 @@ from yarl import URL
from skyvern.config import settings
from skyvern.constants import BROWSER_DOWNLOAD_TIMEOUT, BROWSER_DOWNLOADING_SUFFIX, REPO_ROOT_DIR
from skyvern.exceptions import DownloadFileMaxSizeExceeded, DownloadFileMaxWaitingTime
from skyvern.exceptions import DownloadFileMaxSizeExceeded, DownloadFileMaxWaitingTime, SkyvernHTTPException
from skyvern.forge import app
from skyvern.utils.url_validators import encode_url
from skyvern.forge.sdk.core.aiohttp_helper import (
SSRFGuardedResolver,
ssrf_guarded_tcp_connector,
strip_cross_origin_redirect_credentials,
validate_and_pin_fetch_url,
validate_and_pin_redirect_url,
)
from skyvern.utils.url_validators import (
MAX_SAFE_REDIRECTS,
SAFE_REDIRECT_STATUS_CODES,
encode_url,
)
if TYPE_CHECKING:
from skyvern.forge.sdk.core.skyvern_context import SkyvernContext
@ -209,65 +221,84 @@ async def download_file(
LOG.info("Downloading file from local file system", url=url)
return local_path
async with aiohttp.ClientSession(raise_for_status=True) as session:
resolver = SSRFGuardedResolver()
current_url = await validate_and_pin_fetch_url(url, resolver)
request_headers = dict(headers or {})
async with aiohttp.ClientSession(
raise_for_status=True, connector=ssrf_guarded_tcp_connector(resolver)
) as session:
LOG.info("Starting to download file", url=url)
encoded_url = encode_url(url)
async with session.get(URL(encoded_url, encoded=True), headers=headers) as response:
# Check the content length if available
if max_size_mb and response.content_length and response.content_length > max_size_mb * 1024 * 1024:
# todo: move to root exception.py
raise DownloadFileMaxSizeExceeded(max_size_mb)
for _ in range(MAX_SAFE_REDIRECTS + 1):
encoded_url = encode_url(current_url)
async with session.get(
URL(encoded_url, encoded=True), headers=request_headers, allow_redirects=False
) as response:
if response.status in SAFE_REDIRECT_STATUS_CODES and response.headers.get("Location"):
next_url = await validate_and_pin_redirect_url(
current_url, response.headers["Location"], resolver
)
request_headers, _ = strip_cross_origin_redirect_credentials(
request_headers, None, current_url, next_url
)
current_url = next_url
continue
# Get the file name
if output_dir:
download_dir_path = Path(output_dir)
download_dir_path.mkdir(parents=True, exist_ok=True)
else:
download_dir_path = Path(make_temp_directory(prefix="skyvern_downloads_"))
# Check the content length if available
if max_size_mb and response.content_length and response.content_length > max_size_mb * 1024 * 1024:
# todo: move to root exception.py
raise DownloadFileMaxSizeExceeded(max_size_mb)
download_dir_resolved = download_dir_path.resolve()
# response.headers stays a CIMultiDictProxy: dict() would make header
# lookups case-sensitive and miss lowercase wire headers.
file_name = _determine_download_filename(filename, response.headers, url)
allowed_dir = os.path.realpath(download_dir_resolved)
resolved_final_path = os.path.realpath(os.path.join(allowed_dir, file_name))
# sanitize_filename strips separators but keeps dots, so a dots-only name can
# still resolve outside the download dir; require a direct child. Checked
# before streaming so a rejected name downloads zero bytes.
if (
resolved_final_path == allowed_dir
or not resolved_final_path.startswith(allowed_dir + os.sep)
or os.path.dirname(resolved_final_path) != allowed_dir
):
raise ValueError(f"Unsafe filename derived from download: {file_name!r}")
final_path = Path(resolved_final_path)
# Get the file name
if output_dir:
download_dir_path = Path(output_dir)
download_dir_path.mkdir(parents=True, exist_ok=True)
else:
download_dir_path = Path(make_temp_directory(prefix="skyvern_downloads_"))
temp_file = tempfile.NamedTemporaryFile(mode="wb", dir=download_dir_resolved, delete=False)
file_path = Path(temp_file.name).resolve()
if file_path != download_dir_resolved and not file_path.is_relative_to(download_dir_resolved):
temp_file.close()
raise ValueError("Unsafe temporary file path created for download")
download_dir_resolved = download_dir_path.resolve()
# response.headers stays a CIMultiDictProxy: dict() would make header
# lookups case-sensitive and miss lowercase wire headers.
file_name = _determine_download_filename(filename, response.headers, url)
final_path = (download_dir_resolved / file_name).resolve()
# sanitize_filename strips separators but keeps dots, so a dots-only name can
# still resolve outside the download dir; require a direct child. Checked
# before streaming so a rejected name downloads zero bytes.
if (
not final_path.is_relative_to(download_dir_resolved)
or final_path.parent != download_dir_resolved
):
raise ValueError(f"Unsafe filename derived from download: {file_name!r}")
LOG.info("Downloading file to temporary path", file_path=str(file_path))
try:
with temp_file as f:
# Write the content of the request into the file
total_bytes_downloaded = 0
async for chunk in response.content.iter_chunked(1024):
f.write(chunk)
total_bytes_downloaded += len(chunk)
if max_size_mb and total_bytes_downloaded > max_size_mb * 1024 * 1024:
raise DownloadFileMaxSizeExceeded(max_size_mb)
temp_file = tempfile.NamedTemporaryFile(mode="wb", dir=download_dir_resolved, delete=False)
file_path = Path(temp_file.name).resolve()
if file_path != download_dir_resolved and not file_path.is_relative_to(download_dir_resolved):
temp_file.close()
raise ValueError("Unsafe temporary file path created for download")
file_path.replace(final_path)
except BaseException:
# An orphaned temp file in a run download dir would get synced to storage
# under the tmpXXXX name; drop it on any failure (incl. cancellation).
file_path.unlink(missing_ok=True)
raise
LOG.info("Downloading file to temporary path", file_path=str(file_path))
try:
with temp_file as f:
# Write the content of the request into the file
total_bytes_downloaded = 0
async for chunk in response.content.iter_chunked(1024):
f.write(chunk)
total_bytes_downloaded += len(chunk)
if max_size_mb and total_bytes_downloaded > max_size_mb * 1024 * 1024:
raise DownloadFileMaxSizeExceeded(max_size_mb)
LOG.info(f"File downloaded successfully to {final_path}")
return str(final_path)
file_path.replace(final_path)
except BaseException:
# An orphaned temp file in a run download dir would get synced to storage
# under the tmpXXXX name; drop it on any failure (incl. cancellation).
file_path.unlink(missing_ok=True)
raise
LOG.info(f"File downloaded successfully to {final_path}")
return str(final_path)
raise SkyvernHTTPException(
message=f"Too many redirects while downloading file: {current_url}",
status_code=HTTPStatus.BAD_REQUEST,
)
except aiohttp.ClientResponseError as e:
LOG.exception(f"Failed to download file, status code: {e.status}")
raise

View file

@ -1,15 +1,124 @@
import asyncio
import ipaddress
import os
import socket
from typing import Any
from urllib.parse import urlparse
import aiofiles
import aiohttp
import structlog
from aiohttp.abc import AbstractResolver, ResolveResult
from aiohttp.resolver import DefaultResolver
from skyvern.exceptions import HttpException
from skyvern.exceptions import HttpException, InvalidUrl
from skyvern.utils.url_validators import (
MAX_SAFE_REDIRECTS,
SAFE_REDIRECT_STATUS_CODES,
resolve_fetch_host_ips,
validate_fetch_url_with_resolved_ips,
validate_redirect_url_with_resolved_ips,
)
LOG = structlog.get_logger()
DEFAULT_REQUEST_TIMEOUT = 30
_REDIRECT_CREDENTIAL_HEADERS = {"authorization", "cookie"}
def _url_origin(url: str) -> tuple[str, str, int | None]:
parsed = urlparse(url)
scheme = parsed.scheme.lower()
port = parsed.port
if port is None:
port = 443 if scheme == "https" else 80 if scheme == "http" else None
return scheme, (parsed.hostname or "").lower().rstrip("."), port
def strip_cross_origin_redirect_credentials(
headers: dict[str, str],
cookies: dict[str, str] | None,
current_url: str,
next_url: str,
) -> tuple[dict[str, str], dict[str, str] | None]:
if _url_origin(current_url) == _url_origin(next_url):
return headers, cookies
return {key: value for key, value in headers.items() if key.lower() not in _REDIRECT_CREDENTIAL_HEADERS}, None
class SSRFGuardedResolver(AbstractResolver):
def __init__(self) -> None:
self._pinned_host_ips: dict[str, tuple[str, ...]] = {}
self._trusted_proxy_hosts: set[str] = set()
self._default_resolver = DefaultResolver()
@staticmethod
def _host_key(host: str) -> str:
return host.strip().lower().rstrip(".")
def pin_host_ips(self, host: str, ips: tuple[str, ...]) -> None:
if not ips:
raise OSError(f"No safe addresses resolved for host: {host}")
self._pinned_host_ips[self._host_key(host)] = ips
def pin_url_ips(self, url: str, ips: tuple[str, ...]) -> None:
host = urlparse(url).hostname
if not host:
raise InvalidUrl(url=url)
self.pin_host_ips(host, ips)
def trust_proxy_url(self, proxy: str) -> None:
host = urlparse(proxy).hostname
if host:
self._trusted_proxy_hosts.add(self._host_key(host))
async def resolve(
self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_UNSPEC
) -> list[ResolveResult]:
results: list[ResolveResult] = []
host_key = self._host_key(host)
if host_key in self._trusted_proxy_hosts:
return await self._default_resolver.resolve(host, port, family)
ips = self._pinned_host_ips.get(host_key)
resolved_ips = await asyncio.to_thread(resolve_fetch_host_ips, host) if ips is None else ips
for ip in resolved_ips:
ip_family = (
socket.AF_INET6 if isinstance(ipaddress.ip_address(ip), ipaddress.IPv6Address) else socket.AF_INET
)
if family not in (socket.AF_UNSPEC, ip_family):
continue
results.append(
{
"hostname": host,
"host": ip,
"port": port,
"family": ip_family,
"proto": 0,
"flags": 0,
}
)
if not results:
raise OSError(f"No safe addresses resolved for host: {host}")
return results
async def close(self) -> None:
await self._default_resolver.close()
async def validate_and_pin_fetch_url(url: str, resolver: SSRFGuardedResolver) -> str:
validated_url, ips = await asyncio.to_thread(validate_fetch_url_with_resolved_ips, url)
resolver.pin_url_ips(validated_url, ips)
return validated_url
async def validate_and_pin_redirect_url(url: str, location: str, resolver: SSRFGuardedResolver) -> str:
validated_url, ips = await asyncio.to_thread(validate_redirect_url_with_resolved_ips, url, location)
resolver.pin_url_ips(validated_url, ips)
return validated_url
def ssrf_guarded_tcp_connector(resolver: SSRFGuardedResolver | None = None) -> aiohttp.TCPConnector:
return aiohttp.TCPConnector(resolver=resolver or SSRFGuardedResolver(), use_dns_cache=False)
async def aiohttp_request(
@ -44,64 +153,86 @@ async def aiohttp_request(
Tuple of (status_code, response_headers, response_body)
where response_body can be dict (for JSON) or str (for text)
"""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session:
# Ensure headers is always a dict for type safety
headers_dict: dict[str, str] = headers or {}
request_kwargs: dict[str, Any] = {
"url": url,
"headers": headers_dict,
"cookies": cookies,
"proxy": proxy,
"allow_redirects": follow_redirects,
}
resolver = SSRFGuardedResolver()
if proxy:
resolver.trust_proxy_url(proxy)
current_url = await validate_and_pin_fetch_url(url, resolver)
request_method = method.upper()
request_headers = dict(headers or {})
request_cookies = cookies
strip_body_headers = False
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=timeout), connector=ssrf_guarded_tcp_connector(resolver)
) as session:
async def build_request_kwargs() -> dict[str, Any]:
headers_dict = dict(request_headers)
if strip_body_headers:
headers_dict.pop("Content-Length", None)
headers_dict.pop("content-length", None)
request_kwargs: dict[str, Any] = {
"headers": headers_dict,
"cookies": request_cookies,
"proxy": proxy,
"allow_redirects": False,
}
if request_method == "GET":
return request_kwargs
# Handle body based on content type and method
if method.upper() != "GET":
# If files are provided, use multipart/form-data
if files:
form = aiohttp.FormData()
# Add files to form
for field_name, file_path in files.items():
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
filename = os.path.basename(file_path)
async with aiofiles.open(file_path, "rb") as f:
file_content = await f.read()
form.add_field(field_name, file_content, filename=filename)
form.add_field(field_name, await f.read(), filename=filename)
# Add data fields to form if provided
if data is not None and isinstance(data, dict):
for key, value in data.items():
form.add_field(key, str(value))
request_kwargs["data"] = form
# Remove Content-Type header if present, let aiohttp set it for multipart
# headers_dict is already typed as dict[str, str] from initialization
if "Content-Type" in headers_dict:
del headers_dict["Content-Type"]
if "content-type" in headers_dict:
del headers_dict["content-type"]
# Explicit overrides first
headers_dict.pop("Content-Type", None)
headers_dict.pop("content-type", None)
elif json_data is not None:
request_kwargs["json"] = json_data
elif data is not None:
content_type = headers_dict.get("Content-Type") or headers_dict.get("content-type") or ""
if "application/json" in content_type.lower():
request_kwargs["json"] = data
else:
request_kwargs["data"] = data
request_kwargs["json" if "application/json" in content_type.lower() else "data"] = data
return request_kwargs
async with session.request(method.upper(), **request_kwargs) as response:
response_headers = dict(response.headers)
for _ in range(MAX_SAFE_REDIRECTS + 1):
request_kwargs = await build_request_kwargs()
request_kwargs["url"] = current_url
async with session.request(request_method, **request_kwargs) as response:
if (
follow_redirects
and response.status in SAFE_REDIRECT_STATUS_CODES
and response.headers.get("Location")
):
next_url = await validate_and_pin_redirect_url(current_url, response.headers["Location"], resolver)
request_headers, request_cookies = strip_cross_origin_redirect_credentials(
request_headers, request_cookies, current_url, next_url
)
current_url = next_url
if response.status == 303 or (response.status in (301, 302) and request_method == "POST"):
request_method = "GET"
strip_body_headers = True
continue
try:
response_body = await response.json()
except (aiohttp.ContentTypeError, Exception):
response_body = await response.text()
response_headers = dict(response.headers)
return response.status, response_headers, response_body
try:
response_body = await response.json()
except (aiohttp.ContentTypeError, Exception):
response_body = await response.text()
return response.status, response_headers, response_body
raise HttpException(400, current_url, "Too many redirects while making HTTP request")
async def aiohttp_get_json(

View file

@ -91,6 +91,7 @@ class SkyvernContext:
copilot_session_id: str | None = None
navigation_goal: str | None = None
navigation_payload: dict[str, Any] | list | str | None = None
download_suffix: str | None = None
totp_codes: dict[str, str | None] = field(default_factory=dict)
active_credential_parameter_key: str | None = None
log: list[dict] = field(default_factory=list)

View file

@ -364,7 +364,8 @@ async def resolve_org_from_api_key(
else:
normalized_api_key, normalization_flags = _normalize_api_key_with_flags(x_api_key)
api_key_debug_fields = _get_api_key_debug_fields(x_api_key, normalized_api_key, normalization_flags)
LOG.error(
# Malformed client key rejected with 403 — a client error, not a server fault.
LOG.warning(
"Error decoding JWT",
error_type=type(exc).__name__,
error_reason=_get_safe_auth_error_reason(exc),

View file

@ -92,6 +92,7 @@ from skyvern.schemas.workflows import BlockResult, BlockStatus, BlockType, FileS
from skyvern.utils.css_selector import build_action_summaries_with_timing
from skyvern.webeye.actions.action_types import ActionType
from skyvern.webeye.actions.actions import Action, DecisiveAction
from skyvern.webeye.cdp_download_interceptor import download_filename_from_suffix
from skyvern.webeye.scraper.scraped_page import ElementTreeFormat
LOG = structlog.get_logger()
@ -2114,14 +2115,15 @@ async def download(
# Skip incomplete downloads
if file_extension == BROWSER_DOWNLOADING_SUFFIX:
continue
final_file_name = download_suffix
target_path = local_download_dir / (final_file_name + file_extension)
counter = 1
while target_path.exists():
final_file_name = f"{download_suffix}_{counter}"
target_path = local_download_dir / (final_file_name + file_extension)
counter += 1
rename_file(file_path, final_file_name + file_extension)
local_basename = Path(file_path).name
existing_names = {
Path(f).name
for f in list_files_in_directory(local_download_dir)
if Path(f).name != local_basename
}
desired_name = download_filename_from_suffix(download_suffix, file_extension, existing_names)
if local_basename != desired_name:
rename_file(file_path, desired_name)
# Upload downloaded files from local filesystem to remote storage
# so that get_downloaded_files() can find them for verification.

View file

@ -1,12 +1,19 @@
import ipaddress
import socket
from http import HTTPStatus
from urllib.parse import quote, urlparse, urlsplit, urlunsplit
from urllib.parse import quote, urljoin, urlparse, urlsplit, urlunsplit
from pydantic import HttpUrl, ValidationError
from skyvern.config import settings
from skyvern.exceptions import BlockedHost, InvalidUrl, SkyvernHTTPException
SAFE_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
MAX_SAFE_REDIRECTS = 10
_BLOCKED_INTERNAL_HOSTNAMES = frozenset({"localhost", "metadata.google.internal", "kubernetes.default.svc"})
_BLOCKED_INTERNAL_SUFFIXES = (".local", ".localhost", ".internal", ".cluster.local")
def strip_query_params(url: str) -> str:
"""Return scheme://host/path with query string, fragment, and userinfo removed.
@ -65,32 +72,146 @@ def prepend_scheme_and_validate_url(url: str) -> str:
return url
def is_blocked_host(host: str) -> bool:
def _normalize_host(host: str) -> str:
# RFC 3986 wraps IPv6 literals in [...]; ip_address() only accepts the bare form.
bare = host[1:-1] if host.startswith("[") and host.endswith("]") else host
return (host[1:-1] if host.startswith("[") and host.endswith("]") else host).strip().lower().rstrip(".")
def _normalize_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
return ip.ipv4_mapped
return ip
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
ip = _normalize_ip(ip)
return bool(
ip.is_private or ip.is_link_local or ip.is_loopback or ip.is_reserved or ip.is_multicast or ip.is_unspecified
)
def _is_allowed_host(host: str) -> bool:
normalized = _normalize_host(host)
ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None
try:
ip = ipaddress.ip_address(bare)
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
ip = _normalize_ip(ipaddress.ip_address(normalized))
except ValueError:
ip = None
except Exception:
return False
candidate_forms = {host.lower(), bare.lower()}
candidate_forms = {host.lower(), normalized}
if ip is not None:
candidate_forms.add(str(ip).lower())
allowed = {h.lower() for h in settings.ALLOWED_HOSTS}
if candidate_forms & allowed:
return bool(candidate_forms & allowed)
def _is_internal_hostname(host: str) -> bool:
normalized = _normalize_host(host)
if normalized in _BLOCKED_INTERNAL_HOSTNAMES:
return True
if normalized.endswith(_BLOCKED_INTERNAL_SUFFIXES):
return True
return normalized.endswith(".svc")
def is_blocked_host(host: str, *, resolve_dns: bool = False) -> bool:
normalized = _normalize_host(host)
if not normalized:
return True
if _is_allowed_host(host):
return False
if ip is not None:
return ip.is_private or ip.is_link_local or ip.is_loopback or ip.is_reserved
blocked = {b.lower().rstrip(".") for b in settings.BLOCKED_HOSTS}
if normalized in blocked or _is_internal_hostname(normalized):
return True
blocked = {b.lower() for b in settings.BLOCKED_HOSTS}
return host.lower() in blocked
ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None
try:
ip = ipaddress.ip_address(normalized)
except ValueError:
ip = None
except Exception:
return True
if ip is not None:
return _is_blocked_ip(ip)
if not resolve_dns:
return False
try:
infos = socket.getaddrinfo(normalized, None, type=socket.SOCK_STREAM)
except OSError:
return True
resolved_any = False
for info in infos:
sockaddr = info[4]
ip_str = sockaddr[0] if sockaddr else None
if not ip_str:
continue
try:
resolved_ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
resolved_any = True
if _is_blocked_ip(resolved_ip):
return True
return not resolved_any
def resolve_fetch_host_ips(host: str) -> tuple[str, ...]:
normalized = _normalize_host(host)
if not normalized:
raise BlockedHost(host=host)
allowed = _is_allowed_host(host)
if not allowed and (normalized in {b.lower().rstrip(".") for b in settings.BLOCKED_HOSTS}):
raise BlockedHost(host=host)
if not allowed and _is_internal_hostname(normalized):
raise BlockedHost(host=host)
try:
ip = ipaddress.ip_address(normalized)
except ValueError:
ip = None
except Exception:
raise BlockedHost(host=host)
if ip is not None:
normalized_ip = _normalize_ip(ip)
if not allowed and _is_blocked_ip(normalized_ip):
raise BlockedHost(host=host)
return (str(normalized_ip),)
try:
infos = socket.getaddrinfo(normalized, None, type=socket.SOCK_STREAM)
except OSError:
raise BlockedHost(host=host)
resolved_ips: list[str] = []
for info in infos:
sockaddr = info[4]
ip_str = sockaddr[0] if sockaddr else None
if not ip_str:
continue
try:
resolved_ip = _normalize_ip(ipaddress.ip_address(ip_str))
except ValueError:
continue
if not allowed and _is_blocked_ip(resolved_ip):
raise BlockedHost(host=host)
resolved_ip_str = str(resolved_ip)
if resolved_ip_str not in resolved_ips:
resolved_ips.append(resolved_ip_str)
if not resolved_ips:
raise BlockedHost(host=host)
return tuple(resolved_ips)
def validate_url(url: str) -> str | None:
@ -109,6 +230,30 @@ def validate_url(url: str) -> str | None:
return str(v)
def validate_fetch_url_with_resolved_ips(url: str) -> tuple[str, tuple[str, ...]]:
try:
url = prepend_scheme_and_validate_url(url=url)
v = HttpUrl(url=url)
except Exception as e:
raise SkyvernHTTPException(message=str(e), status_code=HTTPStatus.BAD_REQUEST)
if not v.host:
raise InvalidUrl(url=url)
return str(v), resolve_fetch_host_ips(v.host)
def validate_fetch_url(url: str) -> str:
return validate_fetch_url_with_resolved_ips(url)[0]
def validate_redirect_url_with_resolved_ips(url: str, location: str) -> tuple[str, tuple[str, ...]]:
return validate_fetch_url_with_resolved_ips(urljoin(url, location))
def validate_redirect_url(url: str, location: str) -> str:
return validate_redirect_url_with_resolved_ips(url, location)[0]
def encode_url(url: str) -> str:
parts = list(urlsplit(url))
# Encode the path while preserving "/" and "%"

View file

@ -126,6 +126,7 @@ from skyvern.webeye.browser_factory import initialize_download_dir
from skyvern.webeye.cdp_download_interceptor import (
DOWNLOAD_MIME_TYPES,
MAX_FILE_SIZE_BYTES,
download_filename_from_suffix,
extract_filename,
is_download_response,
normalize_download_filename,
@ -615,6 +616,13 @@ def _log_select_shadow_match(
def _download_target_path(download_dir: Path, suggested_filename: str | None) -> Path:
filename = Path(suggested_filename or "download").name
stem, suffix = os.path.splitext(filename)
context = skyvern_context.current()
download_suffix = context.download_suffix if context else None
if download_suffix:
# Name the file by the block-configured download_suffix so the watcher syncs the
# request-based name instead of the site's suggested name.
existing = {p.name for p in download_dir.iterdir()} if download_dir.exists() else set()
return download_dir / download_filename_from_suffix(download_suffix, suffix, existing)
return download_dir / f"{uuid.uuid4()}-{stem or 'download'}{suffix}"

View file

@ -183,6 +183,24 @@ def normalize_download_filename(filename: str, content_type: str = "") -> str:
return filename
def download_filename_from_suffix(download_suffix: str, source_extension: str, existing_names: set[str]) -> str:
"""Filename for a download whose block configured ``download_suffix``"""
existing_names = {Path(n).name for n in existing_names} # contract: dedup on basenames, never full paths
name = Path(download_suffix).name # defensive: never let a suffix escape the dir
suffix_ext = Path(name).suffix
if suffix_ext:
stem, ext = name[: -len(suffix_ext)], suffix_ext
else:
stem, ext = name, source_extension or ""
stem = stem or "download"
candidate = f"{stem}{ext}"
counter = 1
while candidate in existing_names:
candidate = f"{stem}_{counter}{ext}"
counter += 1
return candidate
def is_download_response(headers: dict[str, str], status_code: int, resource_type: str = "") -> bool:
"""
Determine if a response is a file download.
@ -333,6 +351,9 @@ class CDPDownloadInterceptor:
if not filename:
filename = f"download_{uuid.uuid4().hex[:8]}{_download_extension_for_content_type(content_type)}"
# download_suffix is NOT applied here: this runs inside CDP callbacks that don't carry the
# step's SkyvernContext, so the suffix could be stale. Run-dir files are renamed to
# download_suffix by _finalize_downloaded_files_for_task instead.
save_path = self._output_dir / filename
# TODO: implement proper filename dedup (e.g., content hash or UUID suffix)
if save_path.exists():