会话管理与 Web 安全 三件套
前三天打完了密码学、协议、传输层的地基, Day 04 回到应用层—— Web 工程师每天都在处理却经常踩坑的三件事: Session Fixation(为什么登录之后必须换 SID)、 CSRF(浏览器为我们带 Cookie 这件好事如何被利用)、 XSS(攻击者用一段 <script> 把整个登录态偷走)。 这一天的目标是: 看完之后, 你能审 Cookie 配置 / 审 CSRF 中间件 / 一眼识破 XSS 注入点。
思维导图
Session 生命周期与 Fixation
Day 01 我们建了一个能跑的 Session 系统, 但当时没讲它的生命周期管理。 "什么时候发新 SID、什么时候失效、什么时候要换"—— 这些时机一旦做错, 会出现一个经典攻击: Session Fixation。 理解它的攻击链, 你就懂了为什么"登录后必须 regenerate session id"是写在所有安全清单第一条的铁律。
Session Fixation 攻击
sm.RenewToken(ctx), gorilla/sessions 用
session.ID = "" 让其重新生成。
权限提升也要换——从普通用户提升为管理员时同样要 regen, 防止"低权限上下文换到高权限"被劫持。Session 完整生命周期
| 阶段 | 动作 | 典型时机 |
|---|---|---|
| 匿名 SID | 第一次访问时分配 (可选 · 用于购物车等) | 访问首页 |
| 登录提升 | 生成全新 SID, 废弃旧的 | 账号密码 / OAuth 回调成功 |
| 权限变化 | 再生成一次 SID | 切换租户 / 提升为管理员 / 切换角色 |
| 空闲超时 | N 分钟没活动 → 失效 | idle = 15 - 30 min (敏感系统) |
| 绝对超时 | 登录后 N 小时无论如何失效 | absolute = 8 - 24 h |
| 登出 | 服务端 store 立即删除该 SID | 用户主动点退出 |
| 全局踢出 | 账号被盗时按 user_id 批量删除该用户全部 SID | 风控触发 / 用户改密 |
Go 实战: 登录后 regenerate SID
// 用 alexedwards/scs · 沿用 Day 01 的设置 func login(w http.ResponseWriter, r *http.Request) { user, ok := verifyCredentials(r) if !ok { http.Error(w, "bad credentials", 401); return } if err := sm.RenewToken(r.Context()); err != nil { // 关键一行 http.Error(w, "renew failed", 500); return } sm.Put(r.Context(), "uid", user.ID) sm.Put(r.Context(), "login_at", time.Now().Unix()) w.Write([]byte("ok")) } func logout(w http.ResponseWriter, r *http.Request) { // 服务端 Store 删 + 客户端 Cookie 过期 _ = sm.Destroy(r.Context()) w.Write([]byte("bye")) }
CSRF: 跨站请求伪造
CSRF 把"浏览器自动带 Cookie"这件设计原本是好事的特性,变成攻击面——
只要受害者还登录着 your-bank.com, 攻击者在 evil.com 放一段
<img src="your-bank.com/transfer?to=attacker">,
浏览器就会自动带上 your-bank 的 Cookie 去发起请求。
防御不靠"加密 Cookie", 靠让伪造者无法构造完整请求。
CSRF 攻击流程
四种防御方案
| 方案 | 原理 | 优势 | 限制 |
|---|---|---|---|
| SameSite Cookie | 浏览器不让跨站请求带这个 Cookie | 零代码, Chrome 默认 Lax | 老浏览器不支持(已基本忽略) |
| Synchronizer Token | 服务端为每个会话生成 token, 放表单隐藏域, 提交时校验 | 最强 · 推荐 · 适合服务端渲染 | 需要在每个 form / AJAX 里塞 token |
| Double Submit Cookie | 服务端发 Cookie + JS 把同值放到 Header, 服务端比对 | 无状态 · 适合 SPA | 实现稍复杂; 必须配 SameSite |
| Origin / Referer 校验 | 检查请求 Header 的 Origin 是不是本站 | 额外一层兜底 | 不能单独依赖(老客户端可能不发) |
Go 实战: gorilla/csrf 中间件
// go get github.com/gorilla/csrf package main import ( "net/http" "github.com/gorilla/csrf" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/transfer", transferHandler) // CSRF 中间件包一层 csrfMW := csrf.Protect( []byte("32-byte-long-auth-key-from-env!!"), csrf.Secure(true), // 生产必开 csrf.HttpOnly(true), csrf.SameSite(csrf.SameSiteLaxMode), csrf.Path("/"), ) _ = http.ListenAndServe(":8080", csrfMW(mux)) } func transferHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { // 把 token 塞进 form, 用户提交时浏览器会带回来 token := csrf.Token(r) fmt.Fprintf(w, `<form method="POST"> <input type="hidden" name="gorilla.csrf.Token" value="%s"> <input name="to"> <input name="amt"> <button>Transfer</button></form>`, token) return } // POST 走到这里, token 已经被中间件校验过 w.Write([]byte("transfer ok")) }
// SPA 场景 · Double Submit Cookie · 自己实现 func csrfMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 写操作才校验 if r.Method == "GET" || r.Method == "HEAD" { ensureCSRFCookie(w, r) next.ServeHTTP(w, r); return } // 取 Cookie 与 Header, 常量时间比较 c, err := r.Cookie("csrf_token") h := r.Header.Get("X-CSRF-Token") if err != nil || h == "" || subtle.ConstantTimeCompare([]byte(c.Value), []byte(h)) != 1 { http.Error(w, "csrf invalid", 403); return } // 兜底: Origin 校验 origin := r.Header.Get("Origin") if origin != "" && origin != "https://app.example.com" { http.Error(w, "bad origin", 403); return } next.ServeHTTP(w, r) }) }
subtle.ConstantTimeCompare (Day 01 / Day 02 反复出现的常量时间比较),防时序侧信道XSS: 跨站脚本注入
XSS 的本质是把数据当成代码执行—— 用户输入的字符串没有被正确转义就放进了 HTML, 浏览器把它当作 <script> 标签解析, 攻击者的代码因此在受害者的浏览器上下文中运行。 一旦得手, 攻击者能干HttpOnly 都防不住的事: 偷 token、操作 DOM、伪造点击、键盘记录, 因为他的脚本就在受害者的页面里——已经"成了"受害者本人。
三种 XSS 类型
防御层级
- 输出编码 (第一道闸门)。 任何用户数据写进 HTML / JS / URL / CSS 时, 必须做对应上下文的转义。Go 用
html/template自动转义, 不要用text/template输出 HTML。 - 输入验证 (兜底)。 上传 / 注册的字段做白名单校验——比如手机号只允许数字, 用户名限制字符集。不要试图"黑名单过滤 <script>"——绕过方式太多。
- CSP (现代护城河)。 Content Security Policy 在响应头里告诉浏览器"只允许从 https://example.com 加载脚本", 即使 XSS 注入了 <script>, 也被浏览器拒绝执行。
- Cookie HttpOnly (止损)。 Day 01 强调过, 加这个属性 JS 偷不到 Cookie——XSS 即使得手, 至少 Session ID 拿不走。
Go html/template 自动转义
// Go 的 html/template 默认就是安全的, 关键是不要绕过它 package main import ( "html/template" "net/http" ) var tpl = template.Must(template.New("x").Parse(` <h1>Hello, {{.Name}}</h1> <p>Your bio: {{.Bio}}</p> <a href="/profile?id={{.ID}}">Edit</a> <script>var user = {{.JSData}};</script> `)) func handler(w http.ResponseWriter, r *http.Request) { // 即使用户名是 <script>alert(1)</script> // html/template 会按上下文自动转义: // - 在 HTML body → <script> // - 在 href 属性 → URL 编码 // - 在 <script> 内 → JSON 安全的字符串字面量 _ = tpl.Execute(w, map[string]any{ "Name": r.URL.Query().Get("name"), "Bio": r.URL.Query().Get("bio"), "ID": r.URL.Query().Get("id"), "JSData": map[string]string{"name": "alice"}, }) }
template.HTML(x) 包在用户输入上——
这是显式告诉模板"这段是可信 HTML, 别转义", 用户输入永远不能这么做。
(2) 拼接 SQL 风格的拼接 HTML 字符串:
"<div>" + userInput + "</div>", 绕过了模板的安全机制。
(3) 前端 element.innerHTML = userInput: DOM XSS 的最常见入口, 应该用 textContent。CSP 入门
# 最严格的 CSP — 只允许同源 + 显式列出的资源 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{随机值}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; font-src 'self' https://fonts.gstatic.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; report-uri /csp-report # 把 nonce 塞进 <script>: 只有带正确 nonce 的脚本能跑 # <script nonce="rA8s..."> ... </script> # 注入的 <script> 没有 nonce → 浏览器拒绝执行
Content-Security-Policy-Report-Only 上线一周, 收集 report-uri 的告警, 修完所有兼容性问题再切到强制模式安全头与 Go 综合中间件
把前三节的所有点合并成一个 Go 中间件—— 安全 Cookie / CSRF / CSP / 浏览器加固头, 一次写好, 全站受益。 这一节也是 30 天里第一次给出"可以直接 copy 到生产"的样板代码。
必备的安全响应头
| Header | 作用 | 推荐值 |
|---|---|---|
| Strict-Transport-Security | 强制 HTTPS, 防降级 | max-age=31536000; includeSubDomains; preload |
| Content-Security-Policy | 限制脚本 / 资源加载源, 防 XSS | default-src 'self'; script-src 'self' 'nonce-...' |
| X-Frame-Options | 禁止被嵌入 iframe, 防点击劫持 | DENY |
| X-Content-Type-Options | 禁止浏览器 MIME sniffing | nosniff |
| Referrer-Policy | 限制 Referer 头泄露 | strict-origin-when-cross-origin |
| Permissions-Policy | 禁用不需要的浏览器 API (摄像头 / 麦克风) | camera=(), microphone=(), geolocation=() |
| Cross-Origin-Opener-Policy | 隔离窗口, 防 Spectre 跨源攻击 | same-origin |
Go 综合安全中间件
// security.go · 一个 middleware 把所有头打齐 package middleware import ( "crypto/rand" "encoding/base64" "fmt" "net/http" ) func SecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 1) 每请求生成一个 CSP nonce, 放进 context 供模板使用 b := make([]byte, 16) _, _ = rand.Read(b) nonce := base64.RawStdEncoding.EncodeToString(b) ctx := context.WithValue(r.Context(), "csp_nonce", nonce) h := w.Header() h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") h.Set("Content-Security-Policy", fmt.Sprintf( "default-src 'self'; script-src 'self' 'nonce-%s'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "frame-ancestors 'none'; base-uri 'self'; form-action 'self'", nonce)) h.Set("X-Frame-Options", "DENY") h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") h.Set("Cross-Origin-Opener-Policy", "same-origin") next.ServeHTTP(w, r.WithContext(ctx)) }) } // 安全 Cookie helper · 一次写好不再思考 func SecureCookie(name, value string, maxAge int) *http.Cookie { return &http.Cookie{ Name: name, Value: value, Path: "/", MaxAge: maxAge, HttpOnly: true, // 防 XSS 偷 Cookie Secure: true, // 仅 HTTPS SameSite: http.SameSiteLaxMode, // 防 CSRF } }
// main.go · 串起来用 func main() { mux := http.NewServeMux() mux.HandleFunc("/login", login) mux.HandleFunc("/me", me) // 中间件栈: 安全头 → CSRF → Session → 业务 handler := middleware.SecurityHeaders( csrf.Protect(secret, csrf.Secure(true))( sm.LoadAndSave(mux), ), ) _ = http.ListenAndServeTLS(":443", "server.crt", "server.key", handler) }
用 securityheaders.com 体检
上线后访问 securityheaders.com/?q=your-site.com, 自动评分。目标拿 A+。
看 Security 标签页
Chrome DevTools → Security 标签查看证书 / TLS 版本 / 主源是否安全; Application → Cookies 查看每个 Cookie 的实际属性。
常见疑问
Q1 SPA + JWT 时代, CSRF 还是真实威胁吗? 我看不少团队完全不做 CSRF 防御。 +
分两种情况:
JWT 放在 Authorization 头 · 不带 Cookie 调用 API: 这种纯前端体系下 CSRF 不存在——浏览器不会自动给 evil.com 发起的请求加上 Authorization: Bearer ...(只有 JS 主动加才会)。代价是 JWT 必须存在 localStorage / 内存里, 暴露 XSS 风险(Day 01 / 09 反复提及)。
JWT 放在 Cookie 里 或仍然用 Session: 浏览器会自动带, CSRF 风险完全存在。SameSite=Lax 帮你挡了大部分场景, 但关键写操作仍建议加 CSRF Token——SameSite 在某些跨域链接、子域名信任、老浏览器场景下并不可靠。
真实的开放平台后台一般是Session + Cookie 体系, CSRF 防御必须做。如果你的团队完全不做, 很可能只是还没出过事。
Q2 SameSite=Lax 默认就开了, 还需要专门做 CSRF Token 吗? +
"Lax 够了"的论点在 90% 场景成立, 但关键写操作不能只靠它。原因:
(1) Lax 允许跨站 GET 跳转: 攻击者从 evil.com 点链接跳到 your-site.com, Cookie 依然会带。如果你的"删除资源"接口接受 GET 请求(RESTful 反模式但很常见), Lax 完全挡不住。
(2) 子域名互信: SameSite 看的是 site(eTLD+1), 不是 origin。如果攻击者控制了 blog.example.com(比如博客平台用户上传内容), 他可以发请求到 api.example.com, Cookie 照样带。
(3) SameSite=None 场景: 某些跨域嵌入(支付 / OAuth callback)必须用 None, 那一刻 CSRF 防护就完全失效。
结论: SameSite 是基础底线, 关键写操作再叠加 CSRF Token + Origin 校验, 不要二选一。
Q3 CSP 配好了是不是就能完全防住 XSS? 还要不要继续做输出转义? +
CSP 不是替代转义, 而是叠加防护。两者关系:
转义 / 编码 是第一道闸门——做对了, XSS 注入压根进不来。
CSP 是兜底——万一某个开发漏了转义, CSP 让"已注入的脚本"也跑不起来(因为没有正确 nonce)。
真实场景 CSP 经常被绕过的姿势: (1) 配置了 'unsafe-inline' 等于禁用了 CSP 主要防线; (2) 信任了过多 CDN, 攻击者上传 JS 到信任的 CDN 就绕过; (3) JSONP 端点暴露在白名单内, 可被用来绕过。
规则: 转义必做; CSP 是降低事故影响面的护城河, 但不能替代基本功。
Q4 JWT 体系下还存在 Session Fixation 吗? +
名字不叫 Fixation, 但类似的攻击仍然存在。攻击模式略有不同:
JWT 是新签发的, 攻击者无法预先持有"将被分配给受害者的 token"——所以经典 Fixation 不直接成立。
但有变体: 登录后没换 refresh token——如果攻击者通过其他方式拿到了用户的 refresh token(XSS / 中间人 / 日志泄露), 用它去刷出新 access token 就能持续登录。所以登录 / 改密 / 重要权限变化时, 应当 主动作废所有旧 refresh token, 重新签发——这就是 JWT 体系的"Fixation 防御"。
另一个常见坑: Refresh Token Rotation 没做——每次用 refresh 换 access 都该同时换发新的 refresh, 旧的立即作废。Day 09 实战会展开。
Q5 为什么 X-XSS-Protection 不在你的安全头清单里? 它名字听起来是为这个设计的。 +
因为它已经被现代浏览器废弃, 应该不要设置, 设了反而有风险。
X-XSS-Protection 是早期 IE / 老 Chrome 的 XSS Auditor 开关。后来研究发现, 这个 Auditor 本身可以被滥用(用攻击 payload 触发它把页面里合法的脚本"关掉", 从而绕过其他防御)——Chrome 在 2019 年完全移除了这个特性, Firefox / Edge 也都不再支持。
2024 年的正确清单是: 用 CSP 替代它, 然后明确不要设 X-XSS-Protection。很多老教程还在推荐 X-XSS-Protection: 1; mode=block——已经过时, 跟着抄会被 securityheaders.com 扣分。
复盘问题
- 用 60 秒讲清 Session Fixation 完整攻击链——攻击者预先做了什么 / 受害者做了什么 / 服务端漏了什么。
- 列出 CSRF 的四种防御方案及它们的相对优缺点。2024 年的实战推荐组合是什么?
- 区分 Stored / Reflected / DOM-based XSS——攻击位置、触发位置、传播范围各有什么不同?
- 写一段 Go 代码: 实现一个安全的 Cookie helper, 接收 name/value/maxAge, 返回带全部安全属性的
*http.Cookie。 - CSP 的 nonce 机制怎么工作? 为什么
'unsafe-inline'几乎等于关闭了 CSP?
今日检查清单
- 能讲清 Session Fixation 的完整攻击链, 以及防御只需要一行代码 (RenewToken)
- 能解释 Session 完整生命周期的 7 个阶段, 知道什么时候必须换 SID
- 能列出 CSRF 的 4 种防御方案以及在 SPA / SSR 中各自的取舍
- 能区分 Stored / Reflected / DOM-based XSS 三种类型
- 跑通 gorilla/csrf 或自己实现的 Double Submit Cookie 中间件
- 能用 Go html/template 写一个会自动转义的页面, 故意输入
<script>验证不会执行 - 给自己的 Demo 服务加上 7 个安全响应头, 用 securityheaders.com 测试拿 A 以上
- 读完 OWASP Session / CSRF / XSS 三份 Cheat Sheet
推荐阅读
OWASP Top 10 (2021)
整个 Web 安全圈的"宪法"。Day 04 的三件套(A01 Broken Access / A03 Injection / A07 Auth Failures)在里面都有专项。每 3-4 年更新一次, 2025 版即将发布。
OWASP Cheat Sheet Series
Session / CSRF / XSS / Cookie 各一份, 每份 5-10 页。比 OWASP Top 10 更细, 是"看完直接能照做"的工程清单。
RFC 6265bis · Cookie 现状
2024 年活跃 IETF draft, SameSite 默认值变化、__Host- 前缀、Partitioned Cookie 等现代 Cookie 行为的权威来源。
gorilla/csrf 源码
Go 圈最被广泛使用的 CSRF 中间件, 不到 1000 行。读完你能写出生产级的 token 校验中间件。
securityheaders.com · csp-evaluator
前者 5 秒给你的站点一个安全头评分; 后者(Google 出品) 帮你评估 CSP 是否真的安全, 还是写了个看着唬人的 unsafe-inline。
Day 05 预告
多因素认证: TOTP / Passkey / WebAuthn
Phase 1 的收官——账号密码这个"你知道什么"的因素已经不够用了, 现代登录系统都在叠加"你拥有什么"(手机 / 安全密钥)。 Day 05 看 TOTP 是怎么用一个 30 秒变化的 6 位数字证明"手机在你手上"; 看 WebAuthn / Passkey 怎么用公私钥彻底干掉密码, 让钓鱼网站从原理上无法骗到凭证。 看完 Day 05, 你应该能给自己的服务接入 TOTP, 并理解为什么 Apple / Google / 微软都在 All-in Passkey。