mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 16:13:15 +00:00
* fix(webhooks): fall back to vision model for Slack/Linear image threads Re-land upstream #1626 onto the modular webhook structure. When a Slack mention or Linear issue carries images but the resolved model is text-only, fall back to a vision-capable model instead of dropping the images. Re-points default_vision_model_pair at the fork's image-capable models (Opus 4.8 default, else any supports_images model) rather than upstream's openai:/anthropic: provider filter. Refs #80, upstream #1626 * fix(slack): persist trace_message_ts so web-handoff updates the trace reply Re-land upstream #1630 onto the modular structure. The first-mention store_slack_run_mapping call did not pass trace_message_ts, so it was never persisted (nothing to preserve from on first mention) and _notify_slack_web_handoff always skipped the trace-reply update on web handoff. Pass it through and cover it with a test. Refs #80, upstream #1630 * feat(slack): include channel context in Slack prompts Re-land upstream #1633 onto the modular structure. Fetch cached Slack channel metadata once per event (_get_slack_channel_context) and thread it through the docs-plz gate, repo resolution, and process_slack_mention so prompts carry the channel name and a clearly-marked untrusted channel description. Avoids duplicate conversations.info calls. Refs #80, upstream #1633 * feat(tools): add slack_start_new_thread breakout tool Re-land upstream #1638 onto the modular structure. Adds the slack_start_new_thread tool (posts a top-level Slack message and dispatches a fresh agent run for a broken-out task via the durable dispatch_agent_run contract), wires it into the agent tool list and tools/__init__, adds prompt guidance, and excludes it from plan mode so it can't bypass the approval flow. Tool imports only live modules. Refs #80, upstream #1638 * feat(plan): notify Slack on plan approval Re-land upstream #1632 onto the modular structure. When a plan is approved via the dashboard approve endpoint, post a thread reply to the originating Slack thread noting the comment count and approver, after the follow-up run is dispatched. Slack post failures never break approval. Adapted to the fork's approve_plan (no plan_markdown read). Refs #80, upstream #1632 * feat(plan): publish plans from sandbox files Re-land upstream #1635 onto the modular structure, completing the partially-ported change so dev is internally consistent. save_plan now takes a plan_file_path, reads the agent-authored Markdown file from /workspace/plans/ (validating extension/location/UTF-8/size) and publishes it, instead of taking a plan_markdown string. Removes write_file/edit_file from PLAN_MODE_EXCLUDED_TOOLS so the agent can author the plan file, updates enter_plan_mode/reject_plan guidance and the e2e fake LLM. Skips the #1610-only update_plan hunk (not on dev). Refs #80, upstream #1635 * fix(security): SSRF-harden server-side image fetch + stop logging raw image URLs INJ-01 (high): fetch_image_block used follow_redirects=True with no per-hop revalidation and discarded the resolved-IP pin, so an attacker-authored Slack/ Linear image URL could 302-redirect the fetch to an internal host / cloud metadata endpoint (blind SSRF), and DNS-rebinding could bypass the one-shot is_url_safe check. Route image fetches through the same per-hop resolve+pin+ revalidate loop the http_request tool uses, lifted into url_safety as the shared request_with_safe_redirects. Also strip the per-host Slack/Linear bearer token on redirect so it can't be replayed to a redirect target. SC-1 (low): linear.py logged full image URLs (which can carry signed tokens) at DEBUG; multimodal logged them at INFO on every fetch. Log host-only. Sink lived in multimodal.py (unchanged by the feature work) but PR #128 widened its reach by no longer dropping images for text-only models. Fixing on the base branch so #130/#129 inherit it on rebase. Adds fetch_image_block SSRF regression tests (redirect-to-internal blocked; auth stripped on redirect).
158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
"""Shared SSRF guard: resolve a URL's host and confirm it is publicly routable.
|
|
|
|
Used by the ``http_request`` tool (which additionally pins the connection and
|
|
re-validates every redirect hop) and by server-side image fetching, so an
|
|
untrusted URL can't reach internal services or the cloud metadata endpoint.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import socket
|
|
from typing import Any
|
|
from urllib.parse import urljoin, urlparse, urlunparse
|
|
|
|
import httpx
|
|
|
|
DEFAULT_MAX_REDIRECTS = 5
|
|
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
|
|
|
|
|
def resolve_and_validate(url: str) -> tuple[bool, str, str | None, list | None]:
|
|
"""Resolve a URL's hostname and check every address is safe to contact.
|
|
|
|
Returns (is_safe, reason, hostname, addr_infos). When safe, the caller pins
|
|
the connection to one of ``addr_infos`` so the request cannot pick up a
|
|
different (e.g. DNS-rebound) address after validation.
|
|
"""
|
|
try:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in {"http", "https"}:
|
|
return False, f"Unsupported URL scheme: {parsed.scheme or '<missing>'}", None, None
|
|
|
|
hostname = parsed.hostname
|
|
if not hostname:
|
|
return False, "Could not parse hostname from URL", None, None
|
|
|
|
try:
|
|
addr_infos = socket.getaddrinfo(hostname, None)
|
|
except socket.gaierror:
|
|
return False, f"Could not resolve hostname: {hostname}", hostname, None
|
|
|
|
if not addr_infos:
|
|
return False, f"Could not resolve hostname: {hostname}", hostname, None
|
|
|
|
for addr_info in addr_infos:
|
|
ip_str = addr_info[4][0]
|
|
try:
|
|
ip = ipaddress.ip_address(ip_str)
|
|
except ValueError:
|
|
return False, f"Could not parse resolved address: {ip_str}", hostname, None
|
|
|
|
# Unwrap IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) so a mapped private
|
|
# address can't slip past the check, then block anything that isn't
|
|
# publicly routable (covers private/loopback/link-local/reserved/
|
|
# unspecified/multicast and the cloud metadata 169.254.0.0/16 range).
|
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
ip = ip.ipv4_mapped
|
|
if not ip.is_global:
|
|
return False, f"URL resolves to blocked address: {ip_str}", hostname, None
|
|
|
|
return True, "", hostname, addr_infos
|
|
except Exception as e: # noqa: BLE001
|
|
return False, f"URL validation error: {e}", None, None
|
|
|
|
|
|
def is_url_safe(url: str) -> tuple[bool, str]:
|
|
"""Check if a URL is safe to request (not targeting private/internal networks)."""
|
|
is_safe, reason, _, _ = resolve_and_validate(url)
|
|
return is_safe, reason
|
|
|
|
|
|
def pinned_url(url: str, ip: str) -> str:
|
|
"""Rewrite ``url`` so the connection targets ``ip`` while keeping the path/query.
|
|
|
|
The original hostname is preserved separately for the ``Host`` header and TLS
|
|
SNI/cert verification (via httpx's ``sni_hostname`` request extension).
|
|
"""
|
|
parsed = urlparse(url)
|
|
host_literal = f"[{ip}]" if ":" in ip else ip
|
|
netloc = f"{host_literal}:{parsed.port}" if parsed.port else host_literal
|
|
return urlunparse(parsed._replace(netloc=netloc))
|
|
|
|
|
|
async def request_with_safe_redirects(
|
|
client: httpx.AsyncClient,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
|
strip_auth_on_redirect: bool = False,
|
|
**kwargs: Any,
|
|
) -> tuple[httpx.Response | None, tuple[str, str] | None]:
|
|
"""Issue a request, validating every redirect target before following it.
|
|
|
|
The hostname is resolved once per hop and the connection is pinned to the
|
|
validated IP, closing the DNS-rebinding race where a controlled resolver
|
|
returns a public IP at validation time and a private IP at connect time.
|
|
|
|
Returns ``(response, None)`` on success, or ``(None, (blocked_url, reason))``
|
|
when a hop fails validation or the redirect budget is exhausted. When
|
|
``strip_auth_on_redirect`` is set, the caller's ``Authorization`` header is
|
|
dropped once the request leaves the original URL, so a bearer token can't be
|
|
replayed to a redirect target the caller never chose to authenticate to.
|
|
"""
|
|
current_method = method.upper()
|
|
current_url = url
|
|
request_kwargs = dict(kwargs)
|
|
# Pop caller headers/extensions ONCE so they're reused on every redirect hop
|
|
# (the per-hop Host + SNI are layered on top each time). Popping inside the
|
|
# loop dropped the caller's Authorization/Accept/etc. on the first redirect.
|
|
caller_headers = dict(request_kwargs.pop("headers", None) or {})
|
|
caller_extensions = dict(request_kwargs.pop("extensions", None) or {})
|
|
|
|
for redirect_count in range(max_redirects + 1):
|
|
is_safe, reason, hostname, addr_infos = resolve_and_validate(current_url)
|
|
if not is_safe or hostname is None or addr_infos is None:
|
|
return None, (current_url, reason)
|
|
|
|
pinned_ip = addr_infos[0][4][0]
|
|
parsed = urlparse(current_url)
|
|
headers = {**caller_headers, "Host": parsed.netloc}
|
|
extensions = {**caller_extensions, "sni_hostname": hostname}
|
|
|
|
response = await client.request(
|
|
current_method,
|
|
pinned_url(current_url, pinned_ip),
|
|
follow_redirects=False,
|
|
headers=headers,
|
|
extensions=extensions,
|
|
**request_kwargs,
|
|
)
|
|
|
|
if response.status_code not in _REDIRECT_CODES:
|
|
return response, None
|
|
|
|
location = response.headers.get("Location")
|
|
if not location:
|
|
return response, None
|
|
|
|
if redirect_count == max_redirects:
|
|
return None, (current_url, "Too many redirects")
|
|
|
|
current_url = urljoin(current_url, location)
|
|
|
|
if strip_auth_on_redirect:
|
|
caller_headers = {
|
|
k: v for k, v in caller_headers.items() if k.lower() != "authorization"
|
|
}
|
|
|
|
if response.status_code == 303 or (
|
|
response.status_code in {301, 302} and current_method not in {"GET", "HEAD"}
|
|
):
|
|
current_method = "GET"
|
|
request_kwargs.pop("data", None)
|
|
request_kwargs.pop("content", None)
|
|
request_kwargs.pop("json", None)
|
|
|
|
return None, (current_url, "Too many redirects")
|