package route import ( "net/http" "os" "strconv" "strings" "sync/atomic" "github.com/sirupsen/logrus" ) // banText 是没有任何封禁图时的响应体 —— 改造前的老行为。 const banText = "403 Forbidden" // maxBanImageBytes 限制封禁图的大小。 // // 不是为了防攻击者(路径来自运维配置),而是防手滑:被封的来源会反复重试, // 每张图都会照常占用出网带宽,几百 KB 的原图能把这条路径变成放大器。 const maxBanImageBytes = 2 << 20 // banResponse 是装载好的封禁响应(图片体 + 内容类型)。 type banResponse struct { body []byte contentType string } // bannedImage 在启动时装载一次,之后每个被封请求只读。 // nil 表示没有可用的图,回退纯文字。 var bannedImage atomic.Pointer[banResponse] // loadBanImage 读取 [security] ban_image_path 指向的图片。 // // 任何失败都不返回 error、不影响启动:封禁响应是最后一道兜底, // 不能因为一张图缺失就变成 500,更不能把站点拦在启动阶段。 // 失败时 bannedImage 保持 nil,writeBanned 会回退成纯文字 "403 Forbidden"。 func loadBanImage(path string) bool { // 先清空:重复调用(测试、将来的热替换)时不至于留着上一次的图。 bannedImage.Store(nil) path = strings.TrimSpace(path) if path == "" { return false } info, err := os.Stat(path) if err != nil { logrus.WithError(err).WithField("path", path). Warn("ban_image_path 不可读,封禁响应回退为纯文字 403") return false } if info.IsDir() { logrus.WithField("path", path). Warn("ban_image_path 是个目录,封禁响应回退为纯文字 403") return false } if info.Size() > maxBanImageBytes { logrus.WithFields(logrus.Fields{"path": path, "size": info.Size(), "max": maxBanImageBytes}). Warn("ban_image_path 超过 2 MB(被封来源会反复重试,大图等于放大出网带宽),已回退为纯文字 403") return false } body, err := os.ReadFile(path) if err != nil { logrus.WithError(err).WithField("path", path). Warn("ban_image_path 读取失败,封禁响应回退为纯文字 403") return false } // 按内容嗅探而不是按扩展名:配成 app.ini / .env 这类文件时必须拒绝, // 否则等于把配置文件原样吐给每一个被封的攻击者。 // DetectContentType 只看前 512 字节,对常见图片格式足够。 ct := http.DetectContentType(body) if !strings.HasPrefix(ct, "image/") { logrus.WithFields(logrus.Fields{"path": path, "detected": ct}). Warn("ban_image_path 不是图片(只接受 image/*:png/jpeg/gif/webp),已回退为纯文字 403") return false } bannedImage.Store(&banResponse{body: body, contentType: ct}) logrus.WithFields(logrus.Fields{"path": path, "type": ct, "size": len(body)}). Info("封禁响应图已装载") return true } // writeBanned 写出「来源已被封禁」的响应。 // // 状态码始终是 403,只决定 body 是什么: // - 配了合法图片 → 直接回图片(顶掉明文,爆破脚本靠读 "403 Forbidden" // 确认自己被拦的那套判断会失效;人肉访问时也能看懂发生了什么); // - 没配 / 配错 → 纯文字 "403 Forbidden",即改造前的老行为。 // // 两条分支都显式给 Content-Length:图片动辄几十 KB,超过 Go 的 2 KB 写缓冲, // 不设就会退回 chunked。no-store 是因为中间层缓存一张 403 没有意义, // 还会让解封后的响应继续命中缓存。 func writeBanned(w http.ResponseWriter, method string) { img := bannedImage.Load() if img == nil { writeBannedText(w, method) return } h := w.Header() h.Set("Content-Type", img.contentType) h.Set("Content-Length", strconv.Itoa(len(img.body))) h.Set("Cache-Control", "no-store, no-cache, must-revalidate") h.Set("X-Robots-Tag", "noindex, nofollow") w.WriteHeader(http.StatusForbidden) // HEAD 不写 body(net/http 也会丢弃,这里省掉一次无意义的拷贝)。 if method != http.MethodHead { _, _ = w.Write(img.body) } } func writeBannedText(w http.ResponseWriter, method string) { h := w.Header() h.Set("Content-Type", "text/plain; charset=utf-8") h.Set("Content-Length", strconv.Itoa(len(banText))) h.Set("Cache-Control", "no-store, no-cache, must-revalidate") w.WriteHeader(http.StatusForbidden) if method != http.MethodHead { _, _ = w.Write([]byte(banText)) } }