From d823f1e8d321ddf6358958a6abbbd68d765814e2 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 20 Sep 2026 11:31:59 +0800 Subject: [PATCH] =?UTF-8?q?test(config):=20=E4=BB=A4=E7=89=8C=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E6=94=B9=E7=94=A8=E4=B8=8D=E5=8F=98=E9=87=8F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9D=A0=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E4=BE=8B=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_loopback_host 是 ensure_bindable 唯一的判据,而它连续两次出问题都说明同一件事: 缺陷不在"哪几个例子"里,而在"按什么判定"里(拿字符串前缀当网段、拿库函数当版本 无关的真相)。逐例枚举的用例天生抓不住这类错误,所以改成四条不变量: - 语料由生成得到,不是手写清单:127/8 的写法交叉相乘,'像回环'的名字由 127....example 生成,另有 RFC1918/CGNAT/link-local/云元数据端点。 - 判为对外的东西,拼写怎么改(空白、方括号、大小写)都不能翻回来。 - ::ffff:A.B.C.D 与 A.B.C.D 的判定必须一致:这条与解释器版本无关,而它盯住的 bug 恰恰是版本相关的(3.11 的 IPv6Address.is_loopback 不认 v4-mapped),于是 CI 的 3.11 腿就能盯住它。 - 我们放行的写法,系统真绑一次后选中的必须是回环地址(localhost 走本机解析器,与 我们的字符串逻辑无关);只覆盖系统确实肯绑的写法,OSError 跳过而不是假装通过。 - 开箱默认值(DEFAULT_BIND_HOST / ServeConfig().host)必须免令牌。 验证:把实现换回未归一化版本并模拟 3.11 语义时三条用例失败(含新加的那条); 同一语义配当前实现则全套照过,没有误报。 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- tests/test_config.py | 126 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index ad6bdc8..efa3904 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,16 +2,20 @@ from __future__ import annotations +import ipaddress import os +import socket import pytest from ponte.config import ( + DEFAULT_BIND_HOST, ConfigError, ConfigNotFoundError, ConfigParseError, ConfigValidationError, JumpHop, + ServeConfig, TunnelConfig, daemon_paths_from_file, ensure_bindable, @@ -1070,25 +1074,125 @@ def test_is_loopback_host(host, expected) -> None: assert is_loopback_host(host) is expected -@pytest.mark.parametrize( - "host", +#: 由回环网段**生成**的写法,而不是手写若干例子。这个文件里那几个 bug 都活在 +#: “按什么判定”里(拿字符串前缀当网段、拿库函数当版本无关的真相),而这类错误 +#: 恰恰挑不出几个例子就碰不到——所以语料得是生成的。 +_LOOPBACK_ADDRESSES = [ + f"127.{a}.{b}.{c}" + for a in (0, 1, 128, 254, 255) + for b in (0, 1, 128, 254, 255) + for c in (0, 1, 128, 254, 255) +] + +#: 必须判为对外的东西:生成出来的“像回环”的名字,加上明摆着外部的地址。 +#: ``127....example`` 就是当年从我眼皮下钻过去的那类形状。 +_EXPOSED_HOSTS = ( [ - "127.corp.example", + f"127.{a}.{b}.{c}.example" + for a in (0, 1, 200, 255) + for b in (0, 1, 200, 255) + for c in (0, 1, 200) + ] + + [ + f"host.{a}.{b}.{c}.internal" + for a in (0, 127) + for b in (0, 1) + for c in (0, 1) + ] + + [ + "127.example.com", "127.0.0.1.example.com", "127.0.0.1.", - "127.attacker.tld", - ], + "localhost.", + "localhost.evil.example", + "notlocalhost", + "", + " ", + "0.0.0.0", + "::", + "::2", + "2001:db8::1", + "fe80::1", + "169.254.169.254", + ] + + [ + f"{a}.{b}.{c}.{d}" + for a, b, c in ((10, 0, 0), (172, 16, 0), (192, 168, 1), (100, 64, 0)) + for d in (0, 1, 254, 255) + ] ) -def test_is_loopback_host_rejects_names_that_merely_start_with_127(host) -> None: - """前缀匹配会放行**主机名**,而名字解析到哪里由域名所有者决定。 + + +def test_exposure_is_never_taken_back_by_a_clever_spelling() -> None: + """判定为对外的东西,不能因为写法狡猾而被当成本机。 ``is_loopback_host`` 是 ``ensure_bindable`` 唯一的判据,也就是看板(服务器 地址、登录用户、转发端口)与网络之间的全部防线:放行一个能解析到公网的名字 - 等于无令牌把内网拓扑绑出去。 + 等于无令牌把内网拓扑绑出去。名单由生成得到,所以"换一批例子"骗不过它。 """ - assert is_loopback_host(host) is False - with pytest.raises(ConfigValidationError): - ensure_bindable(host, "") + for host in _EXPOSED_HOSTS: + assert is_loopback_host(host) is False, host + with pytest.raises(ConfigValidationError): + ensure_bindable(host, "") + + +def test_cosmetic_rewrites_do_not_change_the_verdict() -> None: + """空白、方括号与大小写只是拼写差异,不能翻转判定。 + + 反过来说:任何**改动字符串形状**的判定方式都得在这里站得住,这正是旧的 + ``startswith("127.")`` 站不住的地方——它让拼写参与了安全判定。 + """ + for host in _LOOPBACK_ADDRESSES + ["::1", "::ffff:127.0.0.1", "localhost"]: + assert is_loopback_host(host) is True, host + for rewritten in (f" {host} ", f"[{host}]", host.upper(), host.lower()): + assert is_loopback_host(rewritten) is True, rewritten + + +def test_a_mapped_spelling_is_judged_like_the_address_it_stands_for() -> None: + """``::ffff:A.B.C.D`` 就是 ``A.B.C.D``,两者的判定必须一致。 + + 这条不变量与解释器版本无关,而它盯住的 bug 恰恰是版本相关的:3.11 的 + ``IPv6Address.is_loopback`` 不认 v4-mapped 形式,于是同一个地址在 3.11 上 + 要令牌、在 3.12 上不要——令牌门禁建在库函数上,就等于建在解释器版本上。 + 写成不变量之后,CI 的 3.11 腿就盯得住它。 + """ + for ipv4 in ( + "127.0.0.1", + "127.9.9.9", + "10.0.0.1", + "192.168.1.5", + "0.0.0.0", + "169.254.169.254", + ): + assert is_loopback_host(f"::ffff:{ipv4}") is is_loopback_host(ipv4), ipv4 + + +def test_the_kernel_binds_every_safe_default_to_loopback() -> None: + """不变量:我们放行的写法,系统真的只把它绑在本机上。 + + 这个方向是问系统、不问自己:``localhost`` 走的是本机解析器,与我们的字符串 + 逻辑无关。只覆盖系统确实肯绑的写法(macOS 只为 lo 配了 127.0.0.1,绑 + ``127.1.2.3`` 会被拒——那属于“能不能用”,与“判得对不对”无关),所以 + ``OSError`` 会跳过而不是当成通过:判定本身已由上面两个用例锁住。 + """ + for host in ("127.0.0.1", "127.1.2.3", "127.255.255.254", "localhost", "::1"): + assert is_loopback_host(host) is True, host + family = socket.AF_INET6 if ":" in host else socket.AF_INET + with socket.socket(family, socket.SOCK_STREAM) as sock: + try: + sock.bind((host, 0)) + except OSError: + continue + bound = sock.getsockname()[0] + assert ipaddress.ip_address(bound).is_loopback, (host, bound) + + +def test_the_shipped_default_needs_no_token() -> None: + """开箱即用的默认值必须免令牌,否则 ``ponte serve`` 默认就起不来。""" + assert ServeConfig().host == DEFAULT_BIND_HOST + assert is_loopback_host(DEFAULT_BIND_HOST) is True + ensure_bindable(DEFAULT_BIND_HOST, "") + ensure_bindable(ServeConfig().host, "") def test_ensure_bindable_treats_an_empty_host_as_exposed() -> None: