fix(httpapi): no-store 接线、图片缓存头时序、解压炸弹上限与 RSS 空日期
/api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。
This commit is contained in:
@@ -274,6 +274,10 @@ func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// ---- 图片上传(§7.4)----
|
||||
|
||||
// maxImagePixels 解码像素总数上限(1<<25 ≈ 8K 分辨率 7680×4320 ≈ 3.3×10⁷),
|
||||
// 防高压缩比小体积图片解码后内存放大(解压炸弹)。
|
||||
const maxImagePixels = 1 << 25
|
||||
|
||||
var allowedUploadTypes = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
@@ -340,8 +344,19 @@ func (s *Server) handleAdminImageUpload(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
|
||||
return
|
||||
}
|
||||
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流
|
||||
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流。
|
||||
// 先 DecodeConfig 限制像素总数:防 ≤5MB 高压缩比图片解码后撑爆内存
|
||||
// (解压炸弹 OOM,评审 round2 P2-12)。
|
||||
if magicMime != "image/webp" {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
|
||||
return
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片像素总数超过上限")
|
||||
return
|
||||
}
|
||||
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
|
||||
return
|
||||
|
||||
@@ -65,6 +65,11 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
||||
Description: html,
|
||||
})
|
||||
}
|
||||
// 无公开笔记时省略 lastBuildDate(零值 Format 会产出公元 1 年的非法日期)
|
||||
lastBuildStr := ""
|
||||
if !lastBuild.IsZero() {
|
||||
lastBuildStr = lastBuild.Format(time.RFC1123Z)
|
||||
}
|
||||
feed := rssFeed{
|
||||
Version: "2.0",
|
||||
Channel: rssChannel{
|
||||
@@ -72,7 +77,7 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
||||
Link: base + "/",
|
||||
Description: ss.SiteDesc,
|
||||
Language: "zh-CN",
|
||||
LastBuild: lastBuild.Format(time.RFC1123Z),
|
||||
LastBuild: lastBuildStr,
|
||||
Items: items,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNoStoreHeaders 认证与管理端点响应禁缓存(§9.2,评审 round2 P1-3):
|
||||
// 防登出后 bfcache/历史回退回看管理数据与 CSRF token。
|
||||
func TestNoStoreHeaders(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
// 匿名 /api/me(响应随会话态变化)
|
||||
resp, _ := e.get(e.client(), "/api/me")
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("匿名 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
|
||||
}
|
||||
|
||||
// 登录后 /api/me(含 csrf_token)与 /api/admin/notes
|
||||
c := e.loginAdmin()
|
||||
resp, _ = e.get(c, "/api/me")
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("已认证 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
|
||||
}
|
||||
resp, _ = e.get(c, "/api/admin/notes")
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("/api/admin/notes 期望 Cache-Control: no-store,实际 %q", cc)
|
||||
}
|
||||
|
||||
// /api/auth/* 维持 no-store
|
||||
resp, _ = e.do(c, "POST", "/api/auth/logout", nil, nil)
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("/api/auth/logout 期望 Cache-Control: no-store,实际 %q", cc)
|
||||
}
|
||||
}
|
||||
+26
-10
@@ -18,7 +18,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
|
||||
// 响应随会话态变化且含 CSRF token → no-store。
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if sess, ok := s.sessionFrom(r); ok {
|
||||
s.maybeRotate(w, sess)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
|
||||
@@ -100,8 +102,18 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
prevSlug, prevTitle, _ := s.st.AdjacentPublicNote(note, "prev")
|
||||
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
|
||||
prevSlug, prevTitle, err := s.st.AdjacentPublicNote(note, "prev")
|
||||
if err != nil {
|
||||
s.log.Error("查询上一篇失败", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
nextSlug, nextTitle, err := s.st.AdjacentPublicNote(note, "next")
|
||||
if err != nil {
|
||||
s.log.Error("查询下一篇失败", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": note.ID, "slug": note.Slug, "title": note.Title,
|
||||
"summary": note.Summary, "content": note.Content,
|
||||
@@ -133,7 +145,8 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
|
||||
// 缓存头按可见性分流(§7.4)。
|
||||
// 缓存头按可见性分流(§7.4)。缓存头在数据读取成功后才设置,
|
||||
// 错误路径不携带公开缓存指令(防 404 被 CDN 缓存一年)。
|
||||
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
@@ -156,10 +169,18 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
// 先取数据:数据行竞态缺失时返回的 404 不携带任何缓存指令
|
||||
data, err := s.st.GetImageData(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||
return
|
||||
}
|
||||
if public {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
// 非公开图:每次请求重新判定,禁止缓存
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
w.Header().Set("Content-Type", img.MIME)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
@@ -168,11 +189,6 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
data, err := s.st.GetImageData(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
|
||||
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
|
||||
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
|
||||
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
|
||||
mux.Handle("/api/admin/", s.requireAdmin(adminMux))
|
||||
// NoStore:管理数据(笔记全文/回收站/settings)禁缓存,防登出后 bfcache 回看(§9.2)
|
||||
mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))
|
||||
|
||||
// ---- SPA(兜底,须最后注册)----
|
||||
if ui != nil {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngWithDims 构造仅含文件签名 + IHDR 的 PNG 头(DecodeConfig 只解析头部即可
|
||||
// 得到宽高,无需真实像素数据;CRC 按 PNG 规范计算)。
|
||||
func pngWithDims(w, h uint32) []byte {
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:4], w)
|
||||
binary.BigEndian.PutUint32(ihdr[4:8], h)
|
||||
// 8bit / truecolor / deflate / adaptive / no interlace
|
||||
ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12] = 8, 2, 0, 0, 0
|
||||
|
||||
chunk := bytes.NewBuffer(nil)
|
||||
_ = binary.Write(chunk, binary.BigEndian, uint32(len(ihdr)))
|
||||
chunk.WriteString("IHDR")
|
||||
chunk.Write(ihdr)
|
||||
_ = binary.Write(chunk, binary.BigEndian, crc32.ChecksumIEEE(chunk.Bytes()[4:]))
|
||||
|
||||
out := bytes.NewBuffer(nil)
|
||||
out.Write([]byte{0x89, 'P', 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
|
||||
out.Write(chunk.Bytes())
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// TestUploadPixelBomb 解压炸弹防御(评审 round2 P2-12):小体积超大尺寸图片
|
||||
// 在 DecodeConfig 阶段被 413 拒绝,不进入全量 Decode。
|
||||
func TestUploadPixelBomb(t *testing.T) {
|
||||
// 头部自证合法:DecodeConfig 可解析出宽高
|
||||
bomb := pngWithDims(20000, 20000) // 4 亿像素 > 1<<25
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(bomb)); err != nil {
|
||||
t.Fatalf("夹具应可解析出尺寸: %v", err)
|
||||
}
|
||||
// 正常小图不受影响
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(png1x1)); err != nil {
|
||||
t.Fatalf("1x1 夹具应合法: %v", err)
|
||||
}
|
||||
|
||||
e := newEnv(t)
|
||||
c := e.loginAdmin()
|
||||
// 手工 multipart(uploadPNG 辅助对非 201 会 Fatal)
|
||||
var body bytes.Buffer
|
||||
boundary := "bombboundary456"
|
||||
body.WriteString("--" + boundary + "\r\n")
|
||||
body.WriteString(`Content-Disposition: form-data; name="file"; filename="bomb.png"` + "\r\n")
|
||||
body.WriteString("Content-Type: image/png\r\n\r\n")
|
||||
body.Write(bomb)
|
||||
body.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||
req.Header.Set("Origin", e.ts.URL)
|
||||
req.Header.Set("X-CSRF-Token", e.csrf)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("超大像素图片应 413,实际 %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user