package web import ( "log" "net/http" "net/url" "strings" ) // csrfOriginGuard 挡「跨站发起的写请求」。 // // 会话 cookie 是 SameSite=Lax,跨站 POST 本来就带不上 cookie;这层是纵深防御, // 顺手把「将来有人把 SameSite 放宽」和「同站不同源」这两条路一起堵掉。 // // 判据只认浏览器一定会带的两个头,不需要给每个表单塞 token,因此模板一行都不用改: // - 有 Origin:必须是本站;`Origin: null`(沙箱 iframe / file://)一律拒。 // - 没有 Origin 但有 Referer:必须是本站。 // - 两个都没有:放行 —— 跨站 POST 浏览器必带 Origin(只有 Referrer-Policy 把它 // 一起抹掉的情况,而那时 Lax 已经把 cookie 拦在外面了),所以这里只可能是 // curl / 脚本这类非浏览器客户端。 // // 注意:反代必须把**原始 Host** 传过来(nginx 的 `proxy_set_header Host $host`), // 并且改了 Host 时要带 X-Forwarded-Host;否则本站自己的表单也会被拒。 func (s *Server) csrfOriginGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: default: next.ServeHTTP(w, r) return } if !sameOriginRequest(r) { log.Printf("拒绝跨站写请求 %s %s:Origin=%q Referer=%q Host=%q", r.Method, r.URL.Path, r.Header.Get("Origin"), r.Header.Get("Referer"), r.Host) http.Error(w, "跨站请求已被拒绝", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // sameOriginRequest 判断写请求是不是本站自己发起的。 func sameOriginRequest(r *http.Request) bool { if o := r.Header.Get("Origin"); o != "" { return matchesRequestHost(r, o) } if ref := r.Header.Get("Referer"); ref != "" { return matchesRequestHost(r, ref) } return true } // matchesRequestHost 比较 raw(Origin 或 Referer)的 host 与本请求的 host。 // X-Forwarded-Host 也在候选里:反代改写 Host 时靠它兜底。 func matchesRequestHost(r *http.Request, raw string) bool { u, err := url.Parse(raw) if err != nil || u.Host == "" { return false // 含 "null"、相对地址等无法定位来源的形态 } if strings.EqualFold(u.Host, r.Host) { return true } if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" { if i := strings.IndexByte(xfh, ','); i >= 0 { xfh = xfh[:i] } return strings.EqualFold(u.Host, strings.TrimSpace(xfh)) } return false }