服务器名称指示 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。