From 1272d680a824184fafc77c6adbb812c3eba2a5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 8 Sep 2026 17:32:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(httpapi):=20no-store=20=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E3=80=81=E5=9B=BE=E7=89=87=E7=BC=93=E5=AD=98=E5=A4=B4=E6=97=B6?= =?UTF-8?q?=E5=BA=8F=E3=80=81=E8=A7=A3=E5=8E=8B=E7=82=B8=E5=BC=B9=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E4=B8=8E=20RSS=20=E7=A9=BA=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。 --- internal/httpapi/admin.go | 17 ++++++- internal/httpapi/feed.go | 7 ++- internal/httpapi/nostore_test.go | 32 ++++++++++++ internal/httpapi/public.go | 36 ++++++++++---- internal/httpapi/server.go | 3 +- internal/httpapi/upload_pixel_test.go | 71 +++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 internal/httpapi/nostore_test.go create mode 100644 internal/httpapi/upload_pixel_test.go diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go index 15b2289..6f184e8 100644 --- a/internal/httpapi/admin.go +++ b/internal/httpapi/admin.go @@ -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 diff --git a/internal/httpapi/feed.go b/internal/httpapi/feed.go index ab28400..6bc4d89 100644 --- a/internal/httpapi/feed.go +++ b/internal/httpapi/feed.go @@ -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, }, } diff --git a/internal/httpapi/nostore_test.go b/internal/httpapi/nostore_test.go new file mode 100644 index 0000000..9c928fb --- /dev/null +++ b/internal/httpapi/nostore_test.go @@ -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) + } +} diff --git a/internal/httpapi/public.go b/internal/httpapi/public.go index 0c4d9c2..8d5c451 100644 --- a/internal/httpapi/public.go +++ b/internal/httpapi/public.go @@ -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) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 5220a99..7ab3970 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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 { diff --git a/internal/httpapi/upload_pixel_test.go b/internal/httpapi/upload_pixel_test.go new file mode 100644 index 0000000..6fe0700 --- /dev/null +++ b/internal/httpapi/upload_pixel_test.go @@ -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) + } +}