TLS 版本策略决定一个端点愿意接受哪些握手。应明确最低版本,而不是假定库每次升级后的默认值都符合自己的要求。最高版本是另一项独立约束,没有必要地把它固定死,可能阻止以后采用库已经支持的新协议。无论设置了哪种上下限,都不能替代证书身份验证、合适的算法以及持续的安全更新。

最低版本与最高版本是两回事

RFC 8996 已废弃 TLS 1.0 和 1.1;RFC 9325 同时排除 SSLv2、SSLv3,建议支持 TLS 1.3 并在实现支持时优先协商它,也给出了继续使用 TLS 1.2 时应满足的安全要求。对于客户端群体明确的服务,可以评估兼容性后只允许 TLS 1.3;但不能因为存在更严格的配置,就把所有 TLS 1.2 连接一律说成不安全。

双方都支持 TLS 1.3 时,服务器同时允许 TLS 1.2,不等于攻击者可以随意强迫它们选择最弱的版本。TLS 有经过认证的协商过程,也有针对降级攻击的保护。不过,真实的旧版客户端仍然可能合法选择服务器允许的较旧版本,不安全的重试逻辑也可能削弱策略。最低版本应在每条新连接上执行,不能把“最后总算连上”当作唯一验收标准。

抓包里的版本数字还必须结合字段含义解读。TLS 1.3 为兼容性把 hello 中的旧版本字段设为 0x0303,实际版本则通过 supported_versions 协商。因此,单独看到这个旧字段,不能断定连接正在使用 TLS 1.2。有条件时应读取 TLS 库提供的最终协商状态,而不是从某一个兼容字段猜测协议。

坏掉的样子

浏览器访问成功,只能证明一条被允许的路径可用,不能证明 TLS 1.0 已经被拒绝,也不能证明业务必须支持的 TLS 1.2 客户端仍然可用。正向和反向边界需要分别测试,还要使用正确的目标名称与信任根,避免证书错误掩盖版本策略的效果。

负载均衡器可能在应用收到请求之前就终止了 TLS。客户端到边缘、边缘到源站,是两段不同的策略;不同端口、地址族或监听器也可能加载不同配置。验收时应探测真实用户访问的入口,不能只检查后端守护进程。配置重载成功说明预期配置被尝试应用,而实际握手结果才说明对端真正接受了什么。

Go 的 TLS Config 传给 TLS 函数后不得继续修改,因此本例在启动服务之前构造好各份配置。实际部署应采用服务支持的重载流程,再用新连接验证生效结果,不能在握手仍然使用同一个共享配置时原地改字段。配置对象的并发使用规则,也是调整安全策略时需要遵守的契约。

版本范围有交集也只是必要条件。签名算法、密钥交换组、证书名称或信任链不满足要求,仍然会造成失败。应保留原始错误,不能把所有握手失败都归因为版本不匹配,更不能遇到超时就悄悄降低最低版本。否则,一次临时网络问题可能触发持续存在的安全降级,排障时也很难还原最初的边界。

快速验证

把程序保存为 version_probe.go,使用 Go 1.23 或更新版本运行 go run version_probe.go。两个临时回环 HTTPS 服务使用相同测试逻辑,但具有不同的最低版本。测试证书只在本地测试客户端中受信任,不写入系统证书库;请求使用新的 transport,保持证书验证开启,并设置两秒期限。

服务端范围客户端范围预期结果
1.2–1.31.2–1.3TLS 1.3
1.2–1.3仅 1.2TLS 1.2
1.2–1.3仅 1.1拒绝
仅 1.3仅 1.2拒绝
仅 1.31.2–1.3TLS 1.3
package main

import (
	"crypto/tls"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"runtime"
	"sync/atomic"
	"time"
)

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func probe(server *httptest.Server, minimum, maximum uint16) (uint16, error) {
	config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
	config.MinVersion, config.MaxVersion = minimum, maximum
	check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
	transport := &http.Transport{TLSClientConfig: config, DisableKeepAlives: true}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
	response, err := client.Get(server.URL)
	if err != nil {
		return 0, err
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		return 0, err
	}
	check(response.StatusCode == 200 && string(body) == "ok\n", "unexpected HTTP reply")
	check(response.TLS.HandshakeComplete && len(response.TLS.VerifiedChains) > 0, "verified handshake required")
	return response.TLS.Version, nil
}

func main() {
	var handled atomic.Int64
	start := func(minimum uint16) *httptest.Server {
		server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			handled.Add(1)
			fmt.Fprintln(w, "ok")
		}))
		server.Config.ErrorLog = log.New(io.Discard, "", 0)
		server.TLS = &tls.Config{MinVersion: minimum, MaxVersion: tls.VersionTLS13}
		server.StartTLS()
		return server
	}
	compatible := start(tls.VersionTLS12)
	defer compatible.Close()
	strict := start(tls.VersionTLS13)
	defer strict.Close()

	version, err := probe(compatible, tls.VersionTLS12, tls.VersionTLS13)
	check(err == nil && version == tls.VersionTLS13, "modern peers must select TLS 1.3")
	fmt.Println("server 1.2-1.3, client 1.2-1.3:", tls.VersionName(version))

	version, err = probe(compatible, tls.VersionTLS12, tls.VersionTLS12)
	check(err == nil && version == tls.VersionTLS12, "TLS 1.2-only client must remain compatible")
	fmt.Println("server 1.2-1.3, client 1.2 only:", tls.VersionName(version))

	_, err = probe(compatible, tls.VersionTLS11, tls.VersionTLS11)
	check(err != nil, "client below TLS 1.2 floor must fail")
	_, err = probe(strict, tls.VersionTLS12, tls.VersionTLS12)
	check(err != nil, "client below TLS 1.3 floor must fail")
	check(handled.Load() == 2, "rejected handshakes must not reach HTTP")

	version, err = probe(strict, tls.VersionTLS12, tls.VersionTLS13)
	check(err == nil && version == tls.VersionTLS13, "strict server must still accept TLS 1.3")
	check(handled.Load() == 3, "exactly three HTTP requests must succeed")
	fmt.Println("both below-floor clients rejected; strict server still accepts TLS 1.3")
	fmt.Println(runtime.Version())
}

程序逐一断言表中的组合,并确认被拒绝的握手没有进入 HTTP handler。前两个正常请求之后,计数为二;两个反向测试之后仍然为二;最后,严格服务器接纳支持 TLS 1.3 的客户端,计数才变成三。这样同时检查拒绝边界和保留的正常能力,而不是把所有连接都拒绝后也算“加固成功”。

已核验的 macOS、Go 1.27.0 运行通过全部断言。实验明确设置最高版本,是为了让对照条件可重复,不代表应把固定上限复制到所有生产服务。测试真实执行了协商,不只是读取配置文件;但没有实际运行 SSLv2、SSLv3、TLS 1.0 客户端,也没有注入主动降级攻击或验证生产代理发布。

提高线上最低版本之前,需要盘点仍应支持的客户端,在预发布环境测试实际监听器配置,并观察发布过程中的握手失败。兼容性不符合预期时,应重新评估原来的版本策略,而不是关闭身份验证来恢复表面上的成功。两个开关解决不同问题,不能用牺牲证书验证的方式弥补版本范围与真实用户能力之间的冲突。

延伸阅读

来源