HTTPS、TLS 和证书:前端改造不只是在地址栏加把锁
HTTPS 经常被误解成“运维把证书配上就结束”的事,真正落地到前端项目里,坑往往更多地出在页面这一侧。混合内容会把资源直接拦掉,Cookie 属性不对会让登录态失效,本地开发证书没配好又会把联调流程拖垮。
这次全站升级 HTTPS,才把这些问题一次性全摊到台面上。服务器和证书当然重要,但前端同样要弄明白 HTTP 到底裸在哪、TLS 在补哪块短板、证书链为什么能证明“你连的是你想连的人”,以及页面资源为什么会因为一张图片还是 http:// 就整片报错。
后面不只讲握手流程,也会把混合内容、Cookie、安全头和前端改造里最常踩的坑一起串起来。
开工前先弄清楚:HTTP 到底裸在哪
第一天对方案,我提了个问题:「内部后台也要上吗,内网谁攻击我们」。带我做这次改造的运维同事没直接回答,在跳板机上敲了个 tcpdump,让我在测试后台登录了一次,然后把抓到的包给我看:
1POST /api/login HTTP/1.1 2Host: admin.example.com 3Content-Type: application/x-www-form-urlencoded 4 5username=tom&password=123456
用户名密码明晃晃躺在里面。HTTP 是明文协议——所谓明文,不是「容易被破解」,而是根本没加密,链路上任何一个节点都能直接读。同网段的同事、公司出口的网关、运营商、连了恶意 DNS 的公共 WiFi,只要能截到这个包,tcpdump 加 grep 就能把密码捞出来,不需要任何高深技术。
他又补了明文的第二个问题,比窃听更阴险:篡改。HTTP 没有完整性校验,运营商往页面插广告、把 JS 换掉、给响应尾巴塞一段 iframe,这些年在国内的 HTTP 站点上是家常便饭。我们自己就中过招——七月有个 H5 活动页,用户截图投诉页面底部有条奇怪的浮动广告,我们翻遍源码根本没有那段代码,最后确认是某地市宽带在链路上硬塞的。当时只当奇怪现象处理了,这次才算真正理解成因。
所以 HTTP 的问题可以拆成三句话:能被看见、能被改、看不出有没有被改。内网也一样,八月那次 XSS 工单已经证明了「内部系统没人搞」是幻觉。
HTTPS 到底加了什么
HTTPS 不是另一个协议,它就是 HTTP over TLS——在 HTTP 和 TCP 之间塞了一层 TLS。应用层照样发 HTTP 报文,只是这些报文先进了 TLS 这层「加密管道」。
它解决的恰好是上面三件事:
- 加密传输:链路上的人只能看到一堆密文。
- 身份认证:通过证书确认「我连的确实是 example.com,不是冒充的」。
- 完整性保护:报文被改一个字节,对端校验就会失败、直接断开。
第二点最容易被忽略。光有加密是不够的——如果不能确认对端身份,攻击者完全可以冒充服务器,跟你建一条「加密」连接,你的密文照样是发给他的。这就是中间人攻击(MITM),证书就是用来堵这个洞的。
TLS 握手:一次连接里发生了什么
方案里要定支持的协议版本,我趁机把握手流程啃了一遍。2019 年线上主流还是 TLS 1.2,TLS 1.3 刚开始铺(Chrome、Nginx 1.13+ 都支持了),所以按 1.2 的经典流程说,顺带提 1.3 的改进。
一次 TLS 1.2 握手大致是这样的(以最常见的 ECDHE + RSA 为例):
- ClientHello:客户端把自己支持的 TLS 版本、加密套件列表、一个随机数发过去。
- ServerHello:服务器选定一个加密套件,回一个自己的随机数。
- Certificate:服务器把证书链发给客户端,客户端用本地信任的 CA 公钥去验证。
- ServerKeyExchange:服务器用私钥对 ECDHE 参数签名发过去,客户端用证书里的公钥验签——这一步既交换了密钥材料,也证明了「持有私钥的就是证书拥有者」。
- ClientKeyExchange:客户端把自己的 ECDHE 公钥参数发给服务端。这条消息本身只负责把参数递过去;等双方各自手里都有了对方的公钥参数,才各自在本地完成计算,推导出同一个
pre-master secret,再结合前面两个随机数算出对称会话密钥——共享密钥的推导是双方本地各自完成的动作,不是 ClientKeyExchange 这条消息本身做的事,这个区分一开始很容易搞混。 - Finished:双方用会话密钥加密一段校验数据互发,确认握手没被篡改。
握手完成后,后续的 HTTP 数据全部用这个对称会话密钥加密。
想亲眼看一遍握手,用 openssl,这条命令这周我敲了不下五十次:
1openssl s_client -connect example.com:443 -servername example.com
-servername 千万别漏,它对应 TLS 的 SNI 扩展。现在一台服务器一个 IP 挂几十个域名是常态,不带 SNI 服务器不知道该返回哪张证书,你很可能拿到一张默认证书然后一脸懵。我升级第二天晚上排查「证书域名不匹配」,查了一小时才发现是自己命令里忘了 -servername。
不想开 openssl 也行,curl -v 会把整个握手过程打到 stderr 上:
1curl -v https://example.com 2>&1 | grep -E 'SSL|TLS|subject|issuer' 2# 大致会看到: 3# * SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 4# * subject: CN=example.com ← 证书绑定的域名 5# * issuer: C=US; O=...; CN=... ← 签发它的中间 CA
subject 里的 CN(或 SAN)是这张证书声明的域名,issuer 是上一级 CA——顺着 issuer 往上就是证书链。注意 2>&1:这些行走的是 stderr,不重定向 grep 抓不到。
TLS 1.3 把握手压缩到了 1-RTT,还支持 0-RTT 会话恢复,砍掉了一堆老旧不安全的套件(静态 RSA 密钥交换、CBC 模式那些)。我们的 Nginx 版本够新,直接 1.2 和 1.3 都开了——白捡的性能和安全收益。
对称还是非对称?为什么两个都要
握手里其实混用了两种加密,各干各的活,这是 TLS 设计最精妙的地方。
非对称加密(RSA、ECDSA、ECDHE 这类)有公钥私钥一对,公钥加密私钥解、私钥签名公钥验。好处是双方事先不用共享秘密就能安全协商,但运算很重,拿它加密整个 HTTP body 性能会崩。
对称加密(AES 这类)加解密用同一把钥匙,速度极快,适合加密大量数据,难点在于「这把钥匙怎么安全地告诉对方」——直接发等于没加密。
TLS 的解法是各取所长:用非对称的能力在握手阶段安全地协商出一把对称密钥,之后所有业务数据都用这把对称密钥加密。一次昂贵的非对称运算,换来整条连接的高速对称加密,账算得很清楚。
证书:信任是怎么传递的
证书本质上是一份由 CA(证书颁发机构)签名的「身份声明」,里面写着域名、公钥、有效期、签发者。浏览器拿到证书后会验几件事:
- 是不是由本地信任的 CA(或其信任链)签发的
- 证书里的域名跟访问的域名是否匹配
- 有没有过期
- 有没有被吊销(CRL / OCSP)
这几项普通用户也能眼见为实:地址栏点锁图标,展开「证书」(Chrome 里是「连接是安全的 → 证书有效」),能看到颁发给谁、由谁颁发、有效期起止,跟 curl 看到的 subject/issuer 是同一份信息。
任何一项不过,浏览器不是弹个警告那么客气,而是直接拦在握手阶段、整页打不开,并给具体错误码:域名对不上是 NET::ERR_CERT_COMMON_NAME_INVALID,过期是 NET::ERR_CERT_DATE_INVALID,链不完整或签发方不被信任是 NET::ERR_CERT_AUTHORITY_INVALID。看到 ERR_CERT_* 开头就知道是证书问题、不是后端接口问题,排查方向立刻清晰。这里有条工程纪律我写进了整改文档:永远不要训练用户去点「继续访问(不安全)」。用户一旦养成无视证书警告的习惯,真正的中间人攻击来了他也照点不误。
证书是有链的,不是孤零零一张。典型是「服务器证书 → 中间 CA 证书 → 根 CA 证书」,根证书内置在操作系统和浏览器里。最常见的线上事故就是只配了服务器证书、漏了中间证书。这周我们就实打实栽了一次:切完 HTTPS 后 PC 浏览器一切正常,App 里的 WebView 死活连不上,报「unable to verify the first certificate」。Chrome 会缓存或补全中间证书所以看不出问题,某些 Android 老机型、Java 客户端、curl 就直接炸。最后定位是 Nginx 的 ssl_certificate 只放了 cert.pem 没拼 chain.pem。
正确做法是把服务器证书和中间证书拼成一个 fullchain 文件:
1cat cert.pem intermediate.pem > fullchain.pem
最终的 Nginx 配置长这样:
1server { 2 listen 443 ssl http2; 3 server_name example.com; 4 5 ssl_certificate /etc/nginx/ssl/fullchain.pem; 6 ssl_certificate_key /etc/nginx/ssl/privkey.pem; 7 8 ssl_protocols TLSv1.2 TLSv1.3; 9 ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; 10 ssl_prefer_server_ciphers on; 11 12 # 开启 OCSP Stapling,减少客户端单独查吊销状态的开销 13 ssl_stapling on; 14 ssl_stapling_verify on; 15}
留意第一行的 http2——上了 TLS 之后 HTTP/2 基本是顺手白捡的(浏览器只在 HTTPS 下启用 h2),多路复用对我们这种一个页面几十个静态资源的后台,加载体感提升很明显。这也算升级 HTTPS 的隐藏福利。
另外 80 端口别关,配一条 301 把明文流量都赶到 HTTPS 上:
1server { 2 listen 80; 3 server_name example.com; 4 return 301 https://$host$request_uri; 5}
验证链是否完整,别只信浏览器,用 openssl 看返回码:
1openssl s_client -connect example.com:443 -servername example.com -showcerts 2# 看输出末尾的 Verify return code: 0 (ok)
或者直接上 SSL Labs(ssllabs.com/ssltest)跑一遍,它会把证书链、协议版本、套件强弱、已知漏洞全列出来,给个 A~F 评级。我们刷到 A 才提交验收。
证书从哪来:自签、商业证书与 Let's Encrypt
2019 年了,证书早就不该是花钱的拦路虎。对外域名走公司采购的商业证书,内部系统运维同事直接上了 Let's Encrypt——免费、自动化、90 天有效期的 DV 证书,certbot 几条命令搞定:
1# 安装 certbot 后,让它自动改 Nginx 配置并签发 2sudo certbot --nginx -d example.com -d www.example.com 3 4# 续期(90 天有效期,挂个定时任务自动跑) 5sudo certbot renew --dry-run
90 天看着短,其实是好事——逼着你把续期自动化,再也不会出现「证书过期全站挂掉、半夜被叫起来」的惨剧。运维同事说他以前待过的团队就出过这种事,出事的永远是那个没人记得的老项目。certbot renew 丢进 cron 或 systemd timer 就完了。
几种证书方案的取舍,运维同事给我科普了一轮:
- DV(域名验证):只证明你控制这个域名,签发快、免费,Let's Encrypt 就是这类,绝大多数业务够用。
- OV / EV(组织/扩展验证):验证企业实体,签发慢、要钱。EV 还能在地址栏显示绿色公司名,但今年浏览器已经在陆续弱化这个特殊 UI 了,「视觉信任」价值大幅缩水,普通业务没必要花这个钱。
- 自签证书:自己当 CA 给自己签,浏览器默认不信任,只适合内网/本地开发,绝不能上生产。
本地开发想用 HTTPS 又不想被警告烦,我这周试了 mkcert,它在本机生成一个被系统信任的本地 CA:
1mkcert -install 2mkcert localhost 127.0.0.1 dev.example.local 3# 生成的 .pem 文件喂给 webpack-dev-server / nginx 就行
配到 vue.config.js 的 devServer 里,本地环境就和线上行为对齐了,比手搓 openssl req 自签再手动导入信任体验好太多。本地对齐 HTTPS 很重要,不然「本地好好的,上线全是混合内容报错」——这正是我下一段要讲的。
混合内容(Mixed Content):这周最大的坑归了前端
服务端切完,轮到我了。页面整体走了 HTTPS,但里面又去加载 HTTP 资源,就叫混合内容。这是迁移 HTTPS 时最高频的故障来源,而且基本都落在前端头上。
1<!-- 这种主动内容(active content)会被浏览器直接拦截 --> 2<script src="http://cdn.example.com/app.js"></script> 3 4<!-- 这种被动内容(passive content)会显示但触发警告、地址栏锁丢失 --> 5<img src="http://cdn.example.com/banner.png">
浏览器把混合内容分两档:脚本、iframe、XHR/fetch 这类「主动内容」风险高,直接拦截、功能直接坏;图片、音视频这类「被动内容」一般放行但降级安全标识,小锁没了。
切换当天最坑的一个案例:页面本身全绿,功能看着也正常,但控制台一片红的 Mixed Content。排查半天,是一个第三方统计 SDK 偷偷往 http:// 上报数据。业务无感、日志报警,这种最难查——我是靠 DevTools 的 Network 面板按协议筛出来的。
排查和兜底的几个手段:
- 源头改造:所有资源、接口统一改成
https://,这是根治。我把项目里全局搜http://的结果一条条过了。 - 协议相对 URL(
//cdn.example.com/app.js)前几年很流行,跟随页面协议。但现在不推荐了,含义不清晰、本地file://场景会出问题,直接写死https://更好。 - 自动升级:加一行响应头,让浏览器把页面内所有 HTTP 子资源请求自动升级成 HTTPS,作为过渡期安全网:
1Content-Security-Policy: upgrade-insecure-requests
迁移时要过的清单:静态资源、AJAX 接口、图片、字体、第三方 SDK、WebSocket(ws:// 要改 wss://——我们后台的消息推送就是这里漏的,切完 HTTPS 推送直接哑了,补成 wss:// 才恢复)。漏一个就是一个隐患。
Cookie 的安全属性别漏配
HTTPS 上了,登录态 Cookie 的属性也得配齐,这块我和后端同事一起过的:
1Set-Cookie: token=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Secure:这个 Cookie 只在 HTTPS 请求里发送。不加它,用户哪天手敲了 http 地址,登录态就明文飘出去了。HttpOnly:JavaScript 读不到(document.cookie拿不到),挡住 XSS 偷 token 的常见手法——上个月的 XSS 工单里我们已经补过这个。SameSite:控制跨站请求是否带 Cookie。Lax是不错的默认值,挡掉大部分 CSRF;Strict更严但影响外链跳转后的登录态;None必须搭配Secure。
今年正好是 SameSite 的关键节点——Chrome 已经宣布 Chrome 80 要把未显式声明的 Cookie 默认按 SameSite=Lax 处理,明年初生效。我在整改文档里专门提醒了:依赖跨站 Cookie 的场景(比如嵌在合作方页面里的 iframe 业务)必须提前显式声明 SameSite=None; Secure,否则到时候会「莫名其妙登录态丢了」。趁这次整改一起改掉,比明年临时救火强。
顺手加上 HSTS
验收前我们又碰了一次头:「页面都 301 到 HTTPS 了,还有洞吗?」有——第一次访问用户敲的还是 http://,那一跳仍然是明文,存在被 SSL Strip 攻击的窗口。HSTS(HTTP Strict Transport Security)让浏览器记住「这个域名以后只准 HTTPS」:
1Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
加上之后,浏览器在 max-age 期限内会把对该域名的 HTTP 请求直接在本地改写成 HTTPS,明文请求根本不出门。preload 是更进一步,把域名提交到浏览器内置的预加载列表,连第一次访问都强制 HTTPS。不过 preload 要谨慎,提交后想撤回很麻烦,我们先只上了 max-age 和 includeSubDomains,确认全站和所有子域都铁了心走 HTTPS 再考虑 preload。
验收那天的清单
周五安全部门复测通过,SSL Labs 评级 A,工单关闭。我把这周沉淀的前端侧清单贴在了组内 wiki:
- 所有接口、所有静态资源统一 HTTPS,杜绝混合内容,
ws://记得改wss://。 - 用 CSP 的
upgrade-insecure-requests做迁移期兜底。 - 敏感信息(token、密码、身份证号)绝不放进 URL——URL 会进日志、进 Referer、进浏览器历史。
- 登录态 Cookie 配齐
Secure、HttpOnly、SameSite,跨站场景提前显式声明None; Secure。 - 本地用 mkcert 起 HTTPS,跟生产行为对齐。
- 任何证书警告都当红灯,绝不引导用户绕过。
回头看,HTTPS 解决的就是传输过程中的三件事:加密、身份认证、防篡改。TLS 用非对称加密在握手阶段协商出对称密钥,再用对称加密跑业务数据;证书把「信任」从内置的根 CA 一路传递到你的服务器;前端守好混合内容、Cookie 属性和本地开发这几道关。
这周和运维、后端一起把这套流程走完,最大的收获是把「网络」这块基础从书本落到了手上——以前读 TLS 握手总觉得隔着一层,这次抓着真实的证书链报错、真实的 openssl 输出再看一遍,全通了。只要涉及账号和敏感数据,明文传输就不该存在——而做到这一点的全部代价,不过是一周的协作和几条 Nginx 配置,比出事之后再补便宜太多。