# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import os import socket from unittest import mock from google.adk.tools.load_web_page import load_web_page import google.adk.tools.load_web_page as load_web_page_module import requests def _create_response(html: str) -> requests.Response: response = requests.Response() response.status_code = 200 response._content = html.encode('utf-8') # pylint: disable=protected-access response.url = 'https://example.com' return response def _clear_proxy_env(monkeypatch): for env_var in list(os.environ): if env_var.lower().endswith('_proxy'): monkeypatch.delenv(env_var, raising=False) def test_load_web_page_blocks_file_scheme_urls(monkeypatch): _clear_proxy_env(monkeypatch) mock_get = mock.Mock() monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) mock_send = mock.Mock() monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', mock_send) result = load_web_page('file:///etc/passwd') assert result == 'Failed to fetch url: file:///etc/passwd' mock_get.assert_not_called() mock_send.assert_not_called() def test_load_web_page_blocks_loopback_ip_urls(monkeypatch): _clear_proxy_env(monkeypatch) mock_get = mock.Mock() monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) mock_send = mock.Mock() monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', mock_send) result = load_web_page( 'http://127.0.0.1:19876/latest/meta-data/iam/security-credentials/' ) assert ( result == 'Failed to fetch url:' ' http://127.0.0.1:19876/latest/meta-data/iam/security-credentials/' ) mock_get.assert_not_called() mock_send.assert_not_called() def test_load_web_page_blocks_shared_address_space_urls(monkeypatch): _clear_proxy_env(monkeypatch) mock_get = mock.Mock() monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) mock_send = mock.Mock() monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', mock_send) result = load_web_page('http://100.64.0.1/internal') assert result == 'Failed to fetch url: http://100.64.0.1/internal' mock_get.assert_not_called() mock_send.assert_not_called() def test_load_web_page_blocks_private_hostname_targets(monkeypatch): _clear_proxy_env(monkeypatch) monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock( return_value=[( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('169.254.169.254', 0), )] ), ) mock_get = mock.Mock() monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) mock_send = mock.Mock() monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', mock_send) result = load_web_page('http://metadata.google.internal/computeMetadata/v1/') assert ( result == 'Failed to fetch url:' ' http://metadata.google.internal/computeMetadata/v1/' ) mock_get.assert_not_called() mock_send.assert_not_called() def test_load_web_page_uses_proxy_for_unresolved_public_hostnames(monkeypatch): monkeypatch.setenv('HTTPS_PROXY', 'http://proxy.example.test:8080') monkeypatch.setenv('NO_PROXY', '') monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock(side_effect=AssertionError('unexpected local DNS lookup')), ) monkeypatch.setattr( 'bs4.BeautifulSoup', mock.Mock( return_value=mock.Mock( get_text=mock.Mock( return_value='This page has enough words to keep.' ) ) ), ) mock_get = mock.Mock( return_value=_create_response( '
This page has enough words to keep.
' ) ) monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) mock_send = mock.Mock() monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', mock_send) result = load_web_page('https://does-not-resolve.invalid') assert result == 'This page has enough words to keep.' mock_get.assert_called_once_with( 'https://does-not-resolve.invalid', allow_redirects=False, timeout=load_web_page_module._DEFAULT_TIMEOUT_SECONDS, ) mock_send.assert_not_called() def test_load_web_page_fetches_public_urls_by_pinning_the_resolved_ip( monkeypatch, ): _clear_proxy_env(monkeypatch) monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock( return_value=[( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.34', 0), )] ), ) mock_soup = mock.Mock() mock_soup.get_text.return_value = 'This page has enough words to keep.\ntiny' monkeypatch.setattr('bs4.BeautifulSoup', mock.Mock(return_value=mock_soup)) captured_request: dict[str, object] = {} def _send( self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None, ): del self, stream, timeout, verify, cert captured_request['url'] = request.url captured_request['host_header'] = request.headers['Host'] captured_request['proxies'] = proxies return _create_response( 'This page has enough words to keep.
' 'tiny
' ) monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', _send) mock_get = mock.Mock() monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) result = load_web_page('https://example.com/search?q=adk') assert result == 'This page has enough words to keep.' assert captured_request['url'] == 'https://93.184.216.34/search?q=adk' assert captured_request['host_header'] == 'example.com' assert not captured_request['proxies'] mock_get.assert_not_called() def test_load_web_page_tries_another_resolved_address_after_connect_error( monkeypatch, ): _clear_proxy_env(monkeypatch) monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock( return_value=[ ( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.34', 0), ), ( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.35', 0), ), ] ), ) monkeypatch.setattr( 'bs4.BeautifulSoup', mock.Mock( return_value=mock.Mock( get_text=mock.Mock( return_value='This page has enough words to keep.' ) ) ), ) captured_urls: list[str] = [] def _send( self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None, ): del self, stream, timeout, verify, cert, proxies captured_urls.append(request.url) if len(captured_urls) == 1: raise requests.ConnectionError('first address failed') return _create_response( 'This page has enough words to keep.
' ) monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', _send) mock_get = mock.Mock() monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) result = load_web_page('https://example.com') assert result == 'This page has enough words to keep.' assert captured_urls == [ 'https://93.184.216.34', 'https://93.184.216.35', ] mock_get.assert_not_called() def test_load_web_page_passes_timeout_to_pinned_session(monkeypatch): """Verify that the default timeout is passed to the pinned IP session.""" _clear_proxy_env(monkeypatch) monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock( return_value=[( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.34', 0), )] ), ) monkeypatch.setattr( 'bs4.BeautifulSoup', mock.Mock( return_value=mock.Mock( get_text=mock.Mock( return_value='This page has enough words to keep.' ) ) ), ) captured_timeouts: list[object] = [] def _send( self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None, ): del self, request, stream, verify, cert, proxies captured_timeouts.append(timeout) return _create_response( 'This page has enough words to keep.
' ) monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', _send) load_web_page('https://example.com') assert captured_timeouts == [load_web_page_module._DEFAULT_TIMEOUT_SECONDS] def test_load_web_page_passes_timeout_to_proxied_get(monkeypatch): """Verify that the default timeout is passed to requests.get when proxy is used.""" monkeypatch.setenv('HTTPS_PROXY', 'http://proxy.example.test:8080') monkeypatch.setenv('NO_PROXY', '') monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock(side_effect=AssertionError('unexpected local DNS lookup')), ) monkeypatch.setattr( 'bs4.BeautifulSoup', mock.Mock( return_value=mock.Mock( get_text=mock.Mock( return_value='This page has enough words to keep.' ) ) ), ) mock_get = mock.Mock( return_value=_create_response( 'This page has enough words to keep.
' ) ) monkeypatch.setattr(load_web_page_module.requests, 'get', mock_get) load_web_page('https://does-not-resolve.invalid') mock_get.assert_called_once_with( 'https://does-not-resolve.invalid', allow_redirects=False, timeout=load_web_page_module._DEFAULT_TIMEOUT_SECONDS, ) def test_load_web_page_returns_failure_on_timeout(monkeypatch): """Verify that a timeout exception is converted to a failed to fetch message.""" _clear_proxy_env(monkeypatch) monkeypatch.setattr( load_web_page_module.socket, 'getaddrinfo', mock.Mock( return_value=[( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.34', 0), )] ), ) def _send( self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None, ): del self, request, stream, timeout, verify, cert, proxies raise requests.exceptions.Timeout('boom') monkeypatch.setattr(load_web_page_module.HTTPAdapter, 'send', _send) result = load_web_page('https://example.com') assert result == 'Failed to fetch url: https://example.com'