Skip to content

[OWASP ASI02] Block SSRF in recon-tool URL arguments and gateway base URL#1

Open
bdevierno1 wants to merge 1 commit into
mainfrom
security/asi02-ssrf-tool-url-validation
Open

[OWASP ASI02] Block SSRF in recon-tool URL arguments and gateway base URL#1
bdevierno1 wants to merge 1 commit into
mainfrom
security/asi02-ssrf-tool-url-validation

Conversation

@bdevierno1

Copy link
Copy Markdown
Owner

OWASP Reference

  • Risk: ASI02 — Tool Misuse and Exploitation
  • Catalog: OWASP Top 10 for Agentic Applications 2026
  • Reference: https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/
  • Affected: backend/agent/tools.py:140-144
  • Affected: backend/agent/tools.py:216-220
  • Affected: backend/app/url_guard.py:56-58
  • Affected: backend/agent/vecna_agent.py:48
  • Severity: 7.5 (High)
  • Mitigation: Add is_safe_public_target() call at the start of each recon tool before any outbound HTTP request; complete the RFC 1918 172.16.0.0/12 prefix denylist to cover 172.20–172.31; validate OPENROUTER_API_BASE gateway URL through the SSRF guard before use.
  • Audit-Item: 550b86f6-dbcb-4de3-86cf-065703b23600
  • Validation: 550b86f6-dbcb-4de3-86cf-065703b23600
  • Re-Audit: pending

Reproduction (from validator F-04 verdict)

The API-level guard (is_safe_public_target in main.py:138) runs only at the /api/operations POST endpoint. It does not protect the recon tools that execute downstream. After the API accepts a legitimate public target, an attacker who can inject tool arguments (e.g. via prompt-injection content on the scanned site, or direct tool calls if the agent is exposed) can pass a private URL directly to:

  • analyze_http_security_headers("http://169.254.169.254/latest/meta-data/")
    _analyze_headers_impl calls _normalize_base(url) then client.get(base + "/") with no SSRF check → live request to AWS instance-metadata service.
  • probe_common_paths("http://192.168.1.1/")
    _probe_paths_impl iterates DEFAULT_PATHS against the host with no SSRF check → live requests to the internal gateway/router.

A secondary gap: url_guard.py blocked 172.16172.19 by hostname prefix but missed 172.20172.31 (the rest of RFC 1918 172.16.0.0/12), so a URL like http://172.25.10.5/ bypassed the prefix check (the ipaddress-literal path still caught raw IP literals, but the prefix path was incomplete for prefix-like hostnames).

A third gap: OPENROUTER_API_BASE in vecna_agent.py was read from the environment and used directly as the LLM gateway base URL without SSRF validation, allowing environment-variable injection to redirect LLM API calls to an internal endpoint.

Fix description

1. backend/app/url_guard.py

Replaced the manual tuple ("172.16.", "172.17.", "172.18.", "172.19.", ...) with a compiled regex _PRIVATE_PREFIX_RE that covers the full RFC 1918 172.16.0.0/12 range (second octet 16–31) plus all other private/reserved prefixes. Added 169.254.169.254 and fd00:ec2::254 to _BLOCKED_HOSTNAMES for metadata-endpoint defence-in-depth.

2. backend/agent/tools.py

Added is_safe_public_target(target_url) guard at the start of _analyze_headers_impl and _probe_paths_impl. Either function now returns an error JSON immediately if the URL targets a private/internal address, before any httpx connection is attempted. The guard re-uses the same logic already trusted at the API layer — no new code paths.

3. backend/agent/vecna_agent.py

Introduced _validated_gateway_base(): reads OPENROUTER_API_BASE from the environment, validates it through is_safe_public_target, and raises RuntimeError on a blocked URL. Replaced the bare os.environ.get("OPENROUTER_API_BASE", ...) call in _client_args_for_model with this validated helper.

4. backend/tests/test_url_guard.py (new)

29 assertions: all blocked categories (RFC 1918 full range, loopback, link-local, metadata endpoints, zero address, .localhost subdomains) and public-address allow-cases. Specifically tests 172.20, 172.25, and 172.31 to prove the previously-missing range is now covered.

Out of scope

  • _resolve_dns_impl makes outbound HTTP only to the fixed crt.sh API (not to the target URL) — no SSRF risk; not changed here.
  • vecna_litellm.py low-level LiteLLM patcher: its OPENROUTER_API_BASE read is superseded by the _validated_gateway_base() call in vecna_agent.py (higher in the call stack); patching the lower layer would be redundant. Tracked as a sibling hardening item if desired.
  • DNS-rebinding / time-of-check-time-of-use (TOCTOU) after is_safe_public_target passes: requires a separate server-side DNS pinning or follow_redirects=False hardening pass (different ASI class / scope).

Next-Assignee: vecna-audit-verifier

- url_guard: replace incomplete 172.16-172.19 prefix list with
  _PRIVATE_PREFIX_RE covering the full RFC 1918 172.16.0.0/12 range
  (172.16–172.31); add 169.254.169.254 / fd00:ec2::254 to blocked
  hostname set for metadata-endpoint defence-in-depth.

- tools: call is_safe_public_target() at the top of _analyze_headers_impl
  and _probe_paths_impl before any outbound HTTP request; these functions
  were previously reachable by an LLM-injected URL without hitting the
  API-level guard in main.py.

- vecna_agent: introduce _validated_gateway_base() which reads
  OPENROUTER_API_BASE from env, validates it through is_safe_public_target,
  and raises RuntimeError if the URL targets a private/internal range;
  use it in _client_args_for_model() replacing the bare os.environ.get call.

- tests: add backend/tests/test_url_guard.py with 29 assertions covering
  all blocked classes (RFC 1918 ranges including the previously-missing
  172.20–172.31, loopback, link-local, metadata endpoints, zero address)
  and public-IP/hostname allow-cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant