服务器名称指示 SNI 在 TLS 服务器选择握手参数和证书时,提供客户端希望访问的主机名。本文讨论的是证书认证且没有 early data 的路径,因此选择发生在普通 HTTP 字节发送之前,不是网络上任何字节都还没出现的时候。SNI 只是帮助选择,客户端仍然必须验证返回的证书身份。

同一个地址上的不同决策

客户端把 server_name 放在 ClientHello 中,托管多个名称的服务器可以据此选择证书或安全配置。TLS 1.3 在 ServerHello 之后加密证书消息,所以“选证书和传证书都在加密开始之前完成”不是准确描述。应区分先收到选择提示,和随后如何保护证书及其他握手消息。

SNI 中的 DNS 主机名不等于建立连接时使用的目标 IP,填写名称也不要求实验域名真的经过公共 DNS 解析。RFC 6066 不允许在 HostName 字段直接放入 IPv4、IPv6 字面地址,并规定 DNS 主机名比较不区分大小写。客户端直接按 IP 访问时,可以不发送 DNS SNI,转而验证对应的 IP 身份。

多个站点也不一定要使用多张证书。一张证书可以覆盖多个 SAN,服务器没有收到 SNI 时也可以选择默认证书。客户端是否接受,取决于预期访问名称和信任规则;并不是缺少 SNI 就绝对无法建立安全连接。SNI 的作用是帮助选择,不是凭空建立信任,更不证明请求方拥有该主机名。

坏掉的样子

客户端即使连到了正确 IP,仍可能因为没有传递正确名称而拿到其他站点的默认证书。关闭主机名验证只会隐藏这类问题。应分别检查目标地址、预期 TLS 名称、返回证书的 SAN、信任链和有效期,不能把“地址没错”当成身份已经正确的证据。

HTTP Host 或 HTTP/2 的 authority 属于应用层。TLS 名称选定后,再修改这些字段,并不会重新握手或替换已经选择的证书;一张有效证书也不代表请求方获得了后端所有虚拟主机的访问权限。证书选择与租户路由各有自己的策略,必须检查真实服务在两个边界上的行为。

未知 SNI 可能被服务器拒绝,也可能获得默认证书,具体取决于配置;缺少 SNI 并不总会失败。未使用 ECH 时,普通 ClientHello 中的 SNI 在网络上可见,单纯采用 TLS 1.3 不会把它隐藏。ECH 需要额外的部署支持,也不能消除全部流量特征,因此不能把名称隐私当成无需验证就存在的能力。

快速验证

保存为 sni_probe.go,用 Go 1.23 或更新版本运行 go run sni_probe.go。程序在内存中生成一个短期测试 CA 和两张叶证书,只有本地测试客户端信任这个 CA。所有请求都连接同一个回环 IP,通过 ServerName 提供预期 TLS 名称,无需让测试域名经过公共 DNS;实验不复用 TLS 会话或 HTTP 连接。

测试 CA 与叶证书一小时后过期,这是隔离实验的选择,不是生产证书续期策略。生成了证书,不代表其他客户端就会信任它;本例明确配置的信任根池,才为这些请求建立了验证基础。需要把证书生成、服务端选择和客户端信任分开检查,不能把它们合并成一个含糊的“证书可用”。

package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"net/http"
	"net/http/httptest"
	"runtime"
	"strings"
	"sync/atomic"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

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

func leaf(name string, serial int64, ca *x509.Certificate, signer ed25519.PrivateKey) tls.Certificate {
	public, private, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(serial), DNSNames: []string{name},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		KeyUsage:    x509.KeyUsageDigitalSignature,
		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, template, ca, public, signer)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: private, Leaf: certificate}
}

func main() {
	public, private, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(1), IsCA: true, BasicConstraintsValid: true,
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		KeyUsage: x509.KeyUsageCertSign,
	}
	der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
	must(err)
	ca, err := x509.ParseCertificate(der)
	must(err)
	roots := x509.NewCertPool()
	roots.AddCert(ca)
	alpha := leaf("alpha.example.test", 2, ca, private)
	beta := leaf("beta.example.test", 3, ca, private)
	certificates := map[string]*tls.Certificate{
		"alpha.example.test": &alpha, "beta.example.test": &beta,
	}

	var handled, rejected atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		fmt.Fprintf(w, "%s|%s", r.TLS.ServerName, r.Host)
	}))
	server.Config.ErrorLog = log.New(io.Discard, "", 0)
	server.TLS = &tls.Config{
		MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13,
		Certificates: []tls.Certificate{alpha}, SessionTicketsDisabled: true,
		GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
			certificate, ok := certificates[strings.ToLower(hello.ServerName)]
			if !ok {
				rejected.Add(1)
				return nil, fmt.Errorf("unknown fixture SNI")
			}
			return certificate, nil
		},
	}
	server.StartTLS()
	defer server.Close()

	request := func(name, host string) (string, error) {
		config := &tls.Config{RootCAs: roots, ServerName: name, MinVersion: tls.VersionTLS13}
		transport := &http.Transport{TLSClientConfig: config, DisableKeepAlives: true}
		defer transport.CloseIdleConnections()
		client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
		request, err := http.NewRequest(http.MethodGet, server.URL, nil)
		must(err)
		request.Host = host
		response, err := client.Do(request)
		if err != nil {
			return "", err
		}
		defer response.Body.Close()
		body, err := io.ReadAll(response.Body)
		if err != nil {
			return "", err
		}
		check(response.StatusCode == 200, "unexpected HTTP status")
		check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
		check(string(body) == name+"|"+host, "handler must observe separate SNI and Host")
		certificate := response.TLS.PeerCertificates[0]
		must(certificate.VerifyHostname(name))
		return certificate.DNSNames[0], nil
	}

	for _, name := range []string{"alpha.example.test", "beta.example.test"} {
		selected, err := request(name, name)
		must(err)
		check(selected == name, "SNI must select its matching certificate")
		fmt.Println("SNI", name, "selected", selected)
	}
	selected, err := request("ALPHA.EXAMPLE.TEST", "alpha.example.test")
	must(err)
	check(selected == "alpha.example.test", "DNS name selection must be case-insensitive")
	selected, err = request("alpha.example.test", "beta.example.test")
	must(err)
	check(selected == "alpha.example.test", "HTTP Host must not change the TLS certificate")
	_, err = request("missing.example.test", "missing.example.test")
	check(err != nil && rejected.Load() == 1, "unknown SNI must be rejected by fixture policy")
	_, err = request("", "alpha.example.test")
	var nameError x509.HostnameError
	check(errors.As(err, &nameError), "without DNS SNI, default certificate must fail IP identity")
	check(handled.Load() == 4, "only four verified requests may reach the handler")
	fmt.Println("uppercase name selects alpha; Host override keeps alpha; unknown SNI and IP-name mismatch rejected")
	fmt.Println(runtime.Version())
}

alpha 和 beta 分别选中自己的证书,大写的 alpha 名称也应选中同一张 alpha 证书。客户端保持标准信任链和主机名验证,实验还额外检查了实际返回的证书。四个通过验证的请求进入回显 handler,其中一个刻意使用 alpha SNI 和 beta Host;它认证的仍然是 alpha,证明两个字段是不同输入,并不表示可以据此访问另一个租户的数据。

未知 SNI 被这个测试服务的固定名称策略拒绝。当 ServerName 为空时,请求地址中的 IP 不产生 DNS SNI,服务器选择的默认 alpha 证书又没有对应的 IP 身份,因此主机名验证失败。即使 HTTP Host 填写 alpha,也不能提前改变这个 TLS 验证结果。两个反向测试都没有进入 HTTP handler。

这里的 handler 有意回显两种名称,方便观察它们的差别,不是可直接投入生产的虚拟主机路由器。真实入口仍应按照既定策略解释 HTTP authority 和租户访问权限,不能根据 TLS 握手成功就忽略后续路由与授权检查,也不能把实验中允许的字段差异扩大成任意跨租户请求都可以处理。

已核验的 macOS、Go 1.27.0 运行通过全部断言。名称比较遵循 DNS 大小写语义,但服务只覆盖两个 ASCII 测试名称。私钥与 CA 状态仅保留在内存中,不打印、不安装到全局信任库。实验没有测试 ECH、通配符、国际化域名、HTTP/2 连接合并、会话恢复或真实 CDN;上线前仍需分别验证目标边缘节点的证书选择与应用路由行为。

延伸阅读

来源

  • RFC 6066:TLS 扩展,server_name、名称格式与服务端选择,已核验 2026-09-09。
  • RFC 8446:TLS 1.3,ServerHello 之后的加密握手消息,已核验 2026-09-09。
  • Go crypto/tls,ServerName、GetCertificate 与显式 ECH 配置,已核验 2026-09-09。
  • Go crypto/x509,测试证书生成与基于 SAN 的名称验证,已核验 2026-09-09。
  • Go net/http/httptest,临时回环 TLS 服务,已核验 2026-09-09。