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
632b8ec501
commit
3c4b36a841
9 changed files with 469 additions and 114 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 "%"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue