mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
🔄 synced local 'skyvern/' with remote 'skyvern/'
This commit is contained in:
parent
5142883ed6
commit
c61ffb509d
8 changed files with 433 additions and 109 deletions
|
|
@ -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,70 +221,90 @@ 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}")
|
||||
# Re-raised and handled at the action/block boundary; server rejections are external.
|
||||
LOG.warning(f"Failed to download file, status code: {e.status}", exc_info=True)
|
||||
raise
|
||||
except DownloadFileMaxSizeExceeded as e:
|
||||
LOG.exception(f"Failed to download file, max size exceeded: {e.max_size}")
|
||||
LOG.warning(f"Failed to download file, max size exceeded: {e.max_size}", exc_info=True)
|
||||
raise
|
||||
except PermissionError as e:
|
||||
LOG.warning(
|
||||
|
|
@ -282,6 +314,10 @@ async def download_file(
|
|||
reason=str(e),
|
||||
)
|
||||
raise
|
||||
except aiohttp.InvalidURL:
|
||||
# Malformed customer-provided URL - a client-data error, not a platform fault.
|
||||
LOG.warning("Failed to download file, invalid URL", exc_info=True)
|
||||
raise
|
||||
except Exception:
|
||||
LOG.exception("Failed to download file")
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -4388,7 +4388,8 @@ class WorkflowService:
|
|||
workflow_run = await self.mark_workflow_run_as_canceled(workflow_run_id=workflow_run_id)
|
||||
return workflow_run, True
|
||||
if block_result.status == BlockStatus.failed:
|
||||
LOG.error(
|
||||
# Run-level outcome, recorded as the run's failure_reason below; not a platform fault.
|
||||
LOG.warning(
|
||||
f"Block with type {block.block_type} at index {block_idx}/{blocks_cnt - 1} failed for workflow run {workflow_run_id}",
|
||||
block_type=block.block_type,
|
||||
workflow_run_id=workflow_run_id,
|
||||
|
|
|
|||
|
|
@ -1372,7 +1372,8 @@ async def handle_block_result(
|
|||
)
|
||||
|
||||
elif block_result.status == BlockStatus.failed:
|
||||
LOG.error(
|
||||
# Run-level outcome, surfaced via the run's failure handling below; not a platform fault.
|
||||
LOG.warning(
|
||||
f"Block with type {block.block_type} failed for workflow run {workflow_run_id}",
|
||||
block_type=block.block_type,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
|
|
|
|||
|
|
@ -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 "%"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from skyvern.exceptions import (
|
|||
NoSuitableAutoCompleteOption,
|
||||
OptionIndexOutOfBound,
|
||||
PhoneNumberInputMismatch,
|
||||
SkyvernException,
|
||||
)
|
||||
from skyvern.experimentation.wait_utils import get_or_create_wait_config, get_wait_time
|
||||
from skyvern.forge import app
|
||||
|
|
@ -3748,6 +3749,12 @@ async def handle_select_option_action(
|
|||
return results
|
||||
suggested_value = result.value
|
||||
|
||||
except SkyvernException as e:
|
||||
# Expected selection outcomes on non-standard dropdowns (no matching option,
|
||||
# no incremental elements); recorded as ActionFailure like any other miss.
|
||||
LOG.warning("Custom select error", exc_info=True)
|
||||
results.append(ActionFailure(exception=e))
|
||||
return results
|
||||
except Exception as e:
|
||||
LOG.exception("Custom select error")
|
||||
results.append(ActionFailure(exception=e))
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ async def download_file(
|
|||
try:
|
||||
return await download_file_api(file_url, organization_id=organization_id)
|
||||
except Exception:
|
||||
LOG.exception(
|
||||
# Fully self-recovering: the action proceeds without the file.
|
||||
LOG.warning(
|
||||
"Failed to download file, continuing without it",
|
||||
action=action,
|
||||
file_url=file_url,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue