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
197 lines
6.3 KiB
Python
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"
|