chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# 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"
|
||||
Reference in New Issue
Block a user