HTTP 严格传输安全 HSTS 告诉支持它的客户端:以后访问记住的主机名时使用 HTTPS。它可以消除重定向之前的那次明文 HTTP 请求,但前提是客户端已经通过此前可信响应或受支持的预加载机制知道策略。只在服务器添加一个 Header,不能追溯保护未知客户端第一次发出的 HTTP 请求。

客户端记住的是主机名

客户端通过没有安全错误的 HTTPS 收到有效 Strict-Transport-Security 后,才建立或刷新策略。max-age 是从接收时刻起计算的秒数。明文 HTTP 上的同名 Header 不能建立这种信任;后来某次响应没有 Header,也不会删除尚未过期的记录。状态由各个客户端分别维护,浏览器之间不存在自动共享的统一缓存。

策略范围是 DNS 主机名,不是包含协议和端口的 HTTP origin。includeSubDomains 向下覆盖子域名,不覆盖父域或同级名称。升级 URL 时,显式的 80 端口会改为 443,其他显式端口保持不变。因此,原本在另一个端口上提供的 HTTP-only 服务,也可能在客户端学习策略后变得不可用。

这条规则改变的是客户端处理 URL 的方式,不会自动把服务器的明文监听器变成 TLS 监听器;升级后的目标必须事先具备 HTTPS 服务。下面的实验故意在 HTTP 响应中发送 HSTS,只作为反向测试,并不是部署建议。两个测试服务都不返回重定向,因此从 HTTP URL 进入 TLS handler,证明的是客户端主动升级,而非成功跟随了一串服务端跳转。

HSTS 是升级规则,不是绕过证书验证的开关。支持它的浏览器仍须验证 HTTPS,而且面对 HSTS 主机的证书错误,不提供通常的点击继续入口。它不能修复过期证书、恢复不可用的 TLS 服务,也不能证明认证连接背后的应用内容一定可信。

坏掉的样子

较长的有效期或 includeSubDomains 可能影响被遗忘的服务、委托给其他团队的子域和内部名称。扩大范围前,应先清点这些依赖,从较短策略开始,检查真实入口,并确认续期链路可靠。扫描器建议增加某个字段,并不等于所有受影响主机都已经能够履行承诺。

恢复时也要分清机制。客户端收到可信的 max-age=0 响应后,会移除该主机的动态策略,但不会删除子域独立保存的策略,也不能立刻更新尚未再次连接的离线客户端。仅删除响应头不够;浏览器预加载名单的分发和移除,又是独立流程,不会随着服务端切换字段马上完成。

仅验证 Header 存在,不能证明客户端真的按它行动。测试应使用独立状态库,同时检查初始访问和后续 HTTP URL,避免污染个人浏览器配置。也不要为了看到一次“升级成功”,就关闭证书验证。客户端实现或预加载配置不同,需要分别验收,不能把一个工具的结果套到所有浏览器上。

快速验证

保存为 hsts_probe.go,使用 Go 1.23 或更新版本,以及支持 –hsts 的 curl,运行 go run hsts_probe.go。程序建立两个回环服务并生成临时 P-256 CA,只把公开 CA 证书和 curl 缓存写进独立临时目录,退出时删除该目录。私钥始终留在内存中,不写入文件。

package main

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"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 issue(template, parent *x509.Certificate, public any, signer *ecdsa.PrivateKey) *x509.Certificate {
	der, err := x509.CreateCertificate(rand.Reader, template, parent, public, signer)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return certificate
}

func main() {
	curl, err := exec.LookPath("curl")
	must(err)
	versionCommand := exec.Command(curl, "--disable", "--version")
	versionCommand.Env = append(os.Environ(), "SSLKEYLOGFILE=")
	version, err := versionCommand.Output()
	must(err)
	check(strings.Contains(string(version), "HSTS"), "curl must support HSTS")
	caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	must(err)
	leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	must(err)
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "HSTS fixture CA"},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
	}
	ca := issue(template, template, caKey.Public(), caKey)
	host, child := "hsts.example.test", "child.hsts.example.test"
	leaf := issue(&x509.Certificate{
		SerialNumber: big.NewInt(2), DNSNames: []string{host, child},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}, ca, leafKey.Public(), caKey)
	folder, err := os.MkdirTemp("", "hsts-probe-")
	must(err)
	defer func() { must(os.RemoveAll(folder)) }()
	caFile, cacheFile := filepath.Join(folder, "ca.pem"), filepath.Join(folder, "hsts.txt")
	must(os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw}), 0600))

	var plainCount, secureCount atomic.Int64
	plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		plainCount.Add(1)
		if r.URL.Path == "/learn" {
			w.Header().Set("Strict-Transport-Security", "max-age=3600; includeSubDomains")
		}
		fmt.Fprint(w, "plain")
	}))
	defer plain.Close()
	secure := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		secureCount.Add(1)
		check(r.TLS != nil, "secure handler requires TLS")
		switch r.URL.Path {
		case "/learn":
			w.Header().Set("Strict-Transport-Security", "max-age=3600; includeSubDomains")
		case "/clear":
			w.Header().Set("Strict-Transport-Security", "max-age=0")
		}
		fmt.Fprint(w, "secure")
	}))
	secure.Config.ErrorLog = log.New(io.Discard, "", 0)
	secure.TLS = &tls.Config{
		MinVersion:   tls.VersionTLS12,
		Certificates: []tls.Certificate{{Certificate: [][]byte{leaf.Raw}, PrivateKey: leafKey, Leaf: leaf}},
	}
	secure.StartTLS()
	defer secure.Close()
	plainURL, err := url.Parse(plain.URL)
	must(err)
	secureURL, err := url.Parse(secure.URL)
	must(err)
	check(plainURL.Hostname() == "127.0.0.1" && secureURL.Hostname() == "127.0.0.1", "IPv4 loopback fixtures required")
	request := func(scheme, name, port, route string, trust bool) (string, error) {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		args := []string{
			"--disable", "--noproxy", "*", "--silent", "--show-error", "--fail",
			"--max-time", "3", "--hsts", cacheFile, "--resolve", name + ":" + port + ":127.0.0.1",
		}
		if trust {
			args = append(args, "--cacert", caFile)
		}
		args = append(args, scheme+"://"+name+":"+port+route)
		command := exec.CommandContext(ctx, curl, args...)
		command.Env = append(os.Environ(), "SSLKEYLOGFILE=")
		output, err := command.CombinedOutput()
		return string(output), err
	}
	_, err = request("https", host, secureURL.Port(), "/learn", false)
	var exit *exec.ExitError
	check(errors.As(err, &exit) && exit.ExitCode() == 60, "untrusted HTTPS must fail certificate verification")
	for _, test := range []struct {
		label, scheme, name, port, route, expected string
	}{
		{"HTTP cannot seed HSTS", "http", host, plainURL.Port(), "/learn", "plain"},
		{"still HTTP", "http", host, plainURL.Port(), "/probe", "plain"},
		{"trusted HTTPS seeds HSTS", "https", host, secureURL.Port(), "/learn", "secure"},
		{"HTTP URL upgrades", "http", host, secureURL.Port(), "/probe", "secure"},
		{"child inherits upgrade", "http", child, secureURL.Port(), "/probe", "secure"},
		{"authenticated removal", "https", host, secureURL.Port(), "/clear", "secure"},
		{"parent HTTP restored", "http", host, plainURL.Port(), "/probe", "plain"},
		{"child HTTP restored", "http", child, plainURL.Port(), "/probe", "plain"},
	} {
		output, err := request(test.scheme, test.name, test.port, test.route, true)
		check(err == nil, fmt.Sprintf("curl failed: %v: %s", err, output))
		check(output == test.expected, "wrong transport result")
		fmt.Println(test.label, "->", output)
	}
	check(plainCount.Load() == 4 && secureCount.Load() == 4, "expected four plaintext and four authenticated TLS requests")
	fmt.Println(strings.SplitN(string(version), "\n", 2)[0])
	fmt.Println(runtime.Version())
}

所有测试主机名都通过 –resolve 显式指向回环地址,不访问公共 DNS 或外部站点。–disable 放在 curl 参数最前面,避免加载个人配置;–noproxy 排除代理干扰。所有 curl 子进程都清空 SSLKEYLOGFILE,禁用从环境继承的 TLS 密钥日志。每次传输限时三秒,外层进程还有五秒截止时间。测试 CA 按请求传入,不安装到系统信任库。

第一次 HTTPS 请求没有携带测试 CA,必须以 curl 证书验证错误码 60 失败。随后即使明文 HTTP 响应发送了 HSTS,下一次访问仍然是明文。经过验证的 HTTPS 响应才会写入共享测试缓存;此后父域和子域的 HTTP URL 都进入 TLS handler。

可信的 max-age=0 响应移除父域记录后,父域和子域的 HTTP 请求又能进入明文 handler,因为本例从未给子域设置独立策略。最终两个 handler 各处理四次请求,证书验证失败的那次不进入任何应用 handler。升级过程中,URL 中显式给出的非标准 TLS 端口保持不变。

已核验的 macOS、Go 1.27.0、curl 8.7.1 运行通过全部断言。这验证的是 curl 真实的 HSTS 行为,不是浏览器策略验收。实验没有检查浏览器预加载名单、子域独立策略、等待到期、80 到 443 的端口映射、证书错误界面或生产发布。临时缓存随后被删除,不改变用户已有的持久 HSTS 状态。

延伸阅读

来源