package server import ( "fmt" "net" "net/http" "net/url" "strings" ) // RequestGuard 包在组合根的 Handler 外面。 // Handler 本身不改,测试里的 httptest 请求仍直接打 Handler。 type RequestGuard struct { AuthOn bool AllowedHosts []string Next http.Handler } func (g RequestGuard) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") if isWriteMethod(r.Method) { if msg := rejectWriteOrigin(r, g.AllowedHosts); msg != "" { http.Error(w, msg, http.StatusForbidden) return } } if !g.AuthOn { if msg := rejectHost(r, g.AllowedHosts); msg != "" { http.Error(w, msg, http.StatusForbidden) return } } g.Next.ServeHTTP(w, r) } func isWriteMethod(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: return true default: return false } } func rejectWriteOrigin(r *http.Request, allowed []string) string { origin := r.Header.Get("Origin") if origin == "null" { return "拒绝来源为 null 的写请求。经反向代理访问时,把对外域名加入 allowedHosts。" } if origin != "" { parsed, err := url.Parse(origin) if err != nil || parsed.Host == "" { return "拒绝无法识别来源的写请求。经反向代理访问时,把对外域名加入 allowedHosts。" } if hostsMatch(parsed.Host, r.Host) || hostListed(parsed.Hostname(), allowed) { return "" } return "写请求的来源与访问地址不一致。经反向代理访问时,把对外域名加入 allowedHosts。" } site := r.Header.Get("Sec-Fetch-Site") if site != "" && site != "same-origin" && site != "none" { return "拒绝跨站写请求。经反向代理访问时,把对外域名加入 allowedHosts。" } return "" } func rejectHost(r *http.Request, allowed []string) string { host := requestHostname(r.Host) if host == "" { return "拒绝没有 Host 的请求。经反向代理访问时,把对外域名加入 allowedHosts。" } if strings.EqualFold(host, "localhost") || net.ParseIP(host) != nil || hostListed(host, allowed) { return "" } return fmt.Sprintf("拒绝主机名 %s。经反向代理访问时,把对外域名加入 allowedHosts。", host) } func requestHostname(hostport string) string { if h, _, err := net.SplitHostPort(hostport); err == nil { return strings.Trim(h, "[]") } return strings.Trim(hostport, "[]") } func hostsMatch(a, b string) bool { return strings.EqualFold(requestHostname(a), requestHostname(b)) } func hostListed(host string, allowed []string) bool { for _, item := range allowed { if strings.EqualFold(requestHostname(item), host) { return true } } return false } const pageCSP = "script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; " + "img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https:" const activeCSP = "sandbox allow-scripts allow-popups" func setPageHeaders(w http.ResponseWriter) { w.Header().Set("Content-Security-Policy", pageCSP) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") } func setArtifactHeaders(w http.ResponseWriter, contentType string) { w.Header().Set("X-Content-Type-Options", "nosniff") if isActiveContent(contentType) { w.Header().Set("Content-Security-Policy", activeCSP) } } func isActiveContent(contentType string) bool { ct := strings.ToLower(contentType) return strings.Contains(ct, "text/html") || strings.Contains(ct, "application/xhtml") || strings.Contains(ct, "image/svg") || strings.Contains(ct, "xml") }