Files
google--adk-python/tests/unittests/cli/test_dns_rebinding_protection.py
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

197 lines
6.3 KiB
Python

# 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.
"""Tests for DNS-rebinding protection in _OriginCheckMiddleware."""
from google.adk.cli.api_server import _is_loopback_address
from google.adk.cli.api_server import _is_request_origin_allowed
import pytest
class TestIsLoopbackAddress:
"""Unit tests for _is_loopback_address."""
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"localhost",
"::1",
"[::1]",
"127.0.0.1:8000",
"localhost:8000",
"[::1]:8000",
"127.1.2.3", # any 127.x.x.x is loopback
],
)
def test_loopback_hosts(self, host: str):
assert _is_loopback_address(host), f"{host!r} should be loopback"
@pytest.mark.parametrize(
"host",
[
"evil.com",
"127.evil.com",
"0.0.0.0",
"192.168.1.1",
"10.0.0.1",
"128.0.0.1",
"",
],
)
def test_non_loopback_hosts(self, host: str):
assert not _is_loopback_address(host), f"{host!r} should NOT be loopback"
class TestDnsRebindingProtection:
"""Tests that DNS-rebinding attacks are blocked when server is on loopback."""
def _make_scope(
self, server_host: str = "127.0.0.1", host_header: str = "127.0.0.1:8000"
) -> dict:
"""Build a minimal ASGI scope for testing."""
return {
"type": "http",
"method": "POST",
"server": (server_host, 8000),
"headers": [
(b"host", host_header.encode()),
],
"scheme": "http",
}
# --- DNS rebinding scenarios (should be BLOCKED) ---
def test_dns_rebinding_evil_origin_loopback_server_no_configured_origins(
self,
):
"""Attacker page (evil.com) DNS-rebinds to 127.0.0.1 and sends a POST.
Browser sends Origin: http://evil.com, Host: evil.com.
Server is bound to 127.0.0.1.
No explicit allow-origins configured.
Expected: BLOCKED.
"""
scope = self._make_scope(
server_host="127.0.0.1", host_header="evil.com:8000"
)
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert (
not result
), "DNS-rebinding from evil.com should be blocked on loopback server"
def test_dns_rebinding_127_evil_origin(self):
"""Origin header host starts with '127.' but is a hostname (127.evil.com)."""
scope = self._make_scope(
server_host="127.0.0.1", host_header="127.evil.com:8000"
)
result = _is_request_origin_allowed(
origin="http://127.evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert not result
def test_dns_rebinding_localhost_server(self):
"""Same attack, server bound as 'localhost'."""
scope = self._make_scope(server_host="localhost", host_header="evil.com")
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert not result
def test_dns_rebinding_ipv6_loopback_server(self):
"""Same attack, server bound to ::1."""
scope = self._make_scope(server_host="::1", host_header="evil.com")
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert not result
# --- Legitimate same-origin requests (should be ALLOWED) ---
def test_same_origin_localhost_allowed(self):
"""Legitimate browser request from localhost UI to localhost server."""
scope = self._make_scope(
server_host="127.0.0.1", host_header="127.0.0.1:8000"
)
result = _is_request_origin_allowed(
origin="http://127.0.0.1:8000",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert result, "Same-origin localhost request should be allowed"
def test_same_origin_localhost_named(self):
"""Browser opens http://localhost:8000 -> requests to localhost:8000."""
scope = self._make_scope(
server_host="127.0.0.1", host_header="localhost:8000"
)
result = _is_request_origin_allowed(
origin="http://localhost:8000",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert result
# --- Explicit allow-origins configured (allow-list bypasses DNS guard) ---
def test_explicit_allowlist_overrides_dns_rebinding_guard(self):
"""If the developer explicitly allows evil.com, it should be permitted."""
scope = self._make_scope(server_host="127.0.0.1", host_header="evil.com")
result = _is_request_origin_allowed(
origin="http://evil.com",
scope=scope,
allowed_literal_origins=["http://evil.com"],
allowed_origin_regex=None,
has_configured_allowed_origins=True,
)
assert result, "Explicitly allowed origin should still pass"
# --- Non-loopback server (protection does not apply) ---
def test_non_loopback_server_no_dns_guard(self):
"""Server bound to 0.0.0.0 — DNS guard must not interfere with same-origin check."""
scope = self._make_scope(
server_host="0.0.0.0", host_header="example.com:8000"
)
result = _is_request_origin_allowed(
origin="http://example.com:8000",
scope=scope,
allowed_literal_origins=[],
allowed_origin_regex=None,
has_configured_allowed_origins=False,
)
assert result, "Same-origin on public server should be allowed"