🔒️ security(server): 业务接口保护开关与存活探针
- access_key 改用 subtle.ConstantTimeCompare 常量时间比较,防时序攻击 - 新增 server.protect_all 配置:开启后业务接口(/search、/content 等)也要求访问密钥 - 代理路由(/audio/proxy/、/manga/image/)不参与保护,其 URL 由服务端生成不含 key - 新增 /healthz 存活探针(不校验密钥,供容器/反代使用) - openapi.yaml 补 /healthz 定义,Unauthorized 描述说明 protect_all 行为 - 新增 3 个测试:保护开关放行、healthz、错误响应行为
This commit is contained in:
1 parent
d1774a3902
commit
24c7e4b6fd
5 files changed
+73
-21
No files matched your search
+20
-18
@@ -138,28 +138,30 @@ func setupRoutes() {
|
|||||||
http.HandleFunc("/self-info", handlers.RequireAccessKey(handlers.HandleSelfInfo))
|
http.HandleFunc("/self-info", handlers.RequireAccessKey(handlers.HandleSelfInfo))
|
||||||
http.HandleFunc("/vacuum", handlers.RequireAccessKey(handlers.HandleVacuum))
|
http.HandleFunc("/vacuum", handlers.RequireAccessKey(handlers.HandleVacuum))
|
||||||
|
|
||||||
// API端点
|
http.HandleFunc("/healthz", handlers.HandleHealth)
|
||||||
http.HandleFunc("/search", handlers.HandleSearch)
|
|
||||||
http.HandleFunc("/detail", handlers.HandleDetail)
|
// API端点(protect_all 开启时要求访问密钥;代理路由不包,URL 由服务端生成不含 key)
|
||||||
http.HandleFunc("/info", handlers.HandleDetail)
|
http.HandleFunc("/search", handlers.OptionalAccessKey(handlers.HandleSearch))
|
||||||
http.HandleFunc("/catalog", handlers.HandleCatalog)
|
http.HandleFunc("/detail", handlers.OptionalAccessKey(handlers.HandleDetail))
|
||||||
http.HandleFunc("/directory/all_items", handlers.HandleCatalog)
|
http.HandleFunc("/info", handlers.OptionalAccessKey(handlers.HandleDetail))
|
||||||
http.HandleFunc("/multi-detail", handlers.HandleMultiDetail)
|
http.HandleFunc("/catalog", handlers.OptionalAccessKey(handlers.HandleCatalog))
|
||||||
http.HandleFunc("/multi-content", handlers.HandleMultiContent)
|
http.HandleFunc("/directory/all_items", handlers.OptionalAccessKey(handlers.HandleCatalog))
|
||||||
http.HandleFunc("/content", handlers.HandleContent)
|
http.HandleFunc("/multi-detail", handlers.OptionalAccessKey(handlers.HandleMultiDetail))
|
||||||
http.HandleFunc("/item-summary", handlers.HandleItemSummary)
|
http.HandleFunc("/multi-content", handlers.OptionalAccessKey(handlers.HandleMultiContent))
|
||||||
http.HandleFunc("/category-front", handlers.HandleCategoryFront)
|
http.HandleFunc("/content", handlers.OptionalAccessKey(handlers.HandleContent))
|
||||||
http.HandleFunc("/category-landing", handlers.HandleCategoryLanding)
|
http.HandleFunc("/item-summary", handlers.OptionalAccessKey(handlers.HandleItemSummary))
|
||||||
http.HandleFunc("/audio", handlers.HandleAudio)
|
http.HandleFunc("/category-front", handlers.OptionalAccessKey(handlers.HandleCategoryFront))
|
||||||
|
http.HandleFunc("/category-landing", handlers.OptionalAccessKey(handlers.HandleCategoryLanding))
|
||||||
|
http.HandleFunc("/audio", handlers.OptionalAccessKey(handlers.HandleAudio))
|
||||||
http.HandleFunc("/audio/proxy/", handlers.HandleAudioProxy)
|
http.HandleFunc("/audio/proxy/", handlers.HandleAudioProxy)
|
||||||
http.HandleFunc("/video", handlers.HandleVideo)
|
http.HandleFunc("/video", handlers.OptionalAccessKey(handlers.HandleVideo))
|
||||||
http.HandleFunc("/manga", handlers.HandleManga)
|
http.HandleFunc("/manga", handlers.OptionalAccessKey(handlers.HandleManga))
|
||||||
http.HandleFunc("/manga/image/", handlers.HandleMangaImageProxy)
|
http.HandleFunc("/manga/image/", handlers.HandleMangaImageProxy)
|
||||||
|
|
||||||
// 评论相关端点
|
// 评论相关端点
|
||||||
http.HandleFunc("/chapter_review", handlers.HandleChapterReview)
|
http.HandleFunc("/chapter_review", handlers.OptionalAccessKey(handlers.HandleChapterReview))
|
||||||
http.HandleFunc("/review", handlers.HandleReview)
|
http.HandleFunc("/review", handlers.OptionalAccessKey(handlers.HandleReview))
|
||||||
http.HandleFunc("/review_html", handlers.HandleReviewHTML)
|
http.HandleFunc("/review_html", handlers.OptionalAccessKey(handlers.HandleReviewHTML))
|
||||||
|
|
||||||
// 表情图片静态文件服务
|
// 表情图片静态文件服务
|
||||||
http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji"))))
|
http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji"))))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type ServerConfig struct {
|
|||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
AccessKey string `mapstructure:"access_key"`
|
AccessKey string `mapstructure:"access_key"`
|
||||||
|
ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FanqieConfig 番茄小说配置
|
// FanqieConfig 番茄小说配置
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fanqie/internal/config"
|
"fanqie/internal/config"
|
||||||
"fanqie/internal/fqapi"
|
"fanqie/internal/fqapi"
|
||||||
@@ -122,8 +123,8 @@ func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
|||||||
key = r.Header.Get("X-Access-Key")
|
key = r.Header.Get("X-Access-Key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密钥
|
// 验证密钥(常量时间比较,防时序攻击)
|
||||||
if key != cfg.Server.AccessKey {
|
if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
fmt.Fprintf(w, `{"code": -1, "msg": "访问被拒绝:无效的访问密钥"}`)
|
fmt.Fprintf(w, `{"code": -1, "msg": "访问被拒绝:无效的访问密钥"}`)
|
||||||
@@ -135,6 +136,20 @@ func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptionalAccessKey 业务接口保护(仅 server.protect_all 开启时校验)
|
||||||
|
func OptionalAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
|
cfg := config.GetAppConfig()
|
||||||
|
if cfg == nil || !cfg.Server.ProtectAll {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
return RequireAccessKey(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHealth 存活探针(供容器/反向代理使用)
|
||||||
|
func HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
// HandleMain 主页处理
|
// HandleMain 主页处理
|
||||||
func HandleMain(w http.ResponseWriter, r *http.Request) {
|
func HandleMain(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -40,3 +41,22 @@ func TestHandleDetailMissingParam(t *testing.T) {
|
|||||||
t.Errorf("缺参应返回 400, got %d, body: %s", w.Code, w.Body.String())
|
t.Errorf("缺参应返回 400, got %d, body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOptionalAccessKeyDisabled protect_all 未启用(或配置未加载)时直接放行
|
||||||
|
func TestOptionalAccessKeyDisabled(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
h := OptionalAccessKey(func(w http.ResponseWriter, r *http.Request) { called = true })
|
||||||
|
h(httptest.NewRecorder(), httptest.NewRequest("GET", "/search?query=x", nil))
|
||||||
|
if !called {
|
||||||
|
t.Error("protect_all 未启用时应直接放行")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHealth 存活探针返回 200 ok
|
||||||
|
func TestHandleHealth(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
HandleHealth(w, httptest.NewRequest("GET", "/healthz", nil))
|
||||||
|
if w.Code != 200 || w.Body.String() != "ok" {
|
||||||
|
t.Errorf("应返回 200 ok, got %d %q", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -50,6 +50,20 @@ paths:
|
|||||||
message: { type: string, example: success }
|
message: { type: string, example: success }
|
||||||
data: { nullable: true }
|
data: { nullable: true }
|
||||||
|
|
||||||
|
/healthz:
|
||||||
|
get:
|
||||||
|
tags: [系统]
|
||||||
|
summary: 存活探针
|
||||||
|
description: 供容器/反向代理探活,不校验访问密钥。
|
||||||
|
operationId: getHealthz
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 服务存活
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema: { type: string }
|
||||||
|
example: ok
|
||||||
|
|
||||||
/status:
|
/status:
|
||||||
get:
|
get:
|
||||||
tags: [系统]
|
tags: [系统]
|
||||||
@@ -750,7 +764,7 @@ components:
|
|||||||
|
|
||||||
responses:
|
responses:
|
||||||
Unauthorized:
|
Unauthorized:
|
||||||
description: 访问密钥缺失或无效
|
description: 访问密钥缺失或无效(业务接口仅在 server.protect_all 开启时需要)
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
Reference in new issue
Block a user