加固后台管理安全

This commit is contained in:
yml2213
2026-06-11 07:23:00 +08:00
parent 5255b21141
commit 88b1df64e7
41 changed files with 1276 additions and 293 deletions
+16 -4
View File
@@ -5,12 +5,24 @@ import (
)
type Handler struct {
service *Service
storage *filemodule.Storage
service *Service
storage *filemodule.Storage
externalUploadSecret string
externalUploadAllowedIPs []string
}
const maxExternalUploadBodyBytes = 256 * 1024
func NewHandler(service *Service, storage *filemodule.Storage) *Handler {
return &Handler{service: service, storage: storage}
type HandlerOptions struct {
ExternalUploadSecret string
ExternalUploadAllowedIPs []string
}
func NewHandler(service *Service, storage *filemodule.Storage, opts ...HandlerOptions) *Handler {
handler := &Handler{service: service, storage: storage}
if len(opts) > 0 {
handler.externalUploadSecret = opts[0].ExternalUploadSecret
handler.externalUploadAllowedIPs = opts[0].ExternalUploadAllowedIPs
}
return handler
}
@@ -1,9 +1,17 @@
package listing
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"hfb_sys/backend/pkg/response"
@@ -17,6 +25,10 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) {
response.BadRequest(c, "上传内容过大或读取失败")
return
}
if err := h.verifyExternalUpload(c, raw); err != nil {
writeExternalUploadAuthError(c, err)
return
}
var req ExternalUploadRequest
if err := json.Unmarshal(raw, &req); err != nil {
response.BadRequest(c, "上传 JSON 格式不正确")
@@ -34,6 +46,82 @@ func (h *Handler) ImportExternalUpload(c *gin.Context) {
response.Created(c, result)
}
var (
errExternalUploadSecretMissing = errors.New("external upload secret missing")
errExternalUploadForbiddenIP = errors.New("external upload forbidden ip")
errExternalUploadTimestamp = errors.New("external upload timestamp invalid")
errExternalUploadSignature = errors.New("external upload signature invalid")
)
const externalUploadMaxClockSkew = 5 * time.Minute
func (h *Handler) verifyExternalUpload(c *gin.Context, raw []byte) error {
secret := strings.TrimSpace(h.externalUploadSecret)
if secret == "" {
return errExternalUploadSecretMissing
}
if !externalUploadIPAllowed(c.ClientIP(), h.externalUploadAllowedIPs) {
return errExternalUploadForbiddenIP
}
timestamp := strings.TrimSpace(c.GetHeader("X-HFB-Timestamp"))
signature := strings.TrimSpace(c.GetHeader("X-HFB-Signature"))
if timestamp == "" || signature == "" {
return errExternalUploadSignature
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errExternalUploadTimestamp
}
requestTime := time.Unix(ts, 0)
if time.Since(requestTime) > externalUploadMaxClockSkew || time.Until(requestTime) > externalUploadMaxClockSkew {
return errExternalUploadTimestamp
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(expected)) {
return errExternalUploadSignature
}
return nil
}
func externalUploadIPAllowed(clientIP string, allowed []string) bool {
if len(allowed) == 0 {
return true
}
parsedIP := net.ParseIP(clientIP)
for _, raw := range allowed {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
if parsedIP != nil {
if _, network, err := net.ParseCIDR(item); err == nil && network.Contains(parsedIP) {
return true
}
}
if item == clientIP {
return true
}
}
return false
}
func writeExternalUploadAuthError(c *gin.Context, err error) {
switch err {
case errExternalUploadSecretMissing:
response.ServiceUnavailable(c, "开放导入签名密钥未配置")
case errExternalUploadForbiddenIP:
response.Error(c, http.StatusForbidden, "forbidden", "来源 IP 不允许访问")
case errExternalUploadTimestamp:
response.Error(c, http.StatusUnauthorized, "invalid_timestamp", "请求时间戳无效")
default:
response.Error(c, http.StatusUnauthorized, "invalid_signature", "请求签名无效")
}
}
func (h *Handler) DefaultUploadScreenshot(c *gin.Context) {
c.Header("Content-Type", "image/svg+xml; charset=utf-8")
c.Header("Cache-Control", "public, max-age=86400")
@@ -0,0 +1,96 @@
package listing
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestVerifyExternalUploadSignature(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"data":{"loginMethod":"QQ账号密码"}}`)
secret := "test-upload-secret"
timestamp := time.Now().Unix()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil)
req.Header.Set("X-HFB-Timestamp", strconvFormatInt(timestamp))
req.Header.Set("X-HFB-Signature", signExternalUploadForTest(secret, timestamp, body))
c.Request = req
handler := &Handler{externalUploadSecret: secret}
if err := handler.verifyExternalUpload(c, body); err != nil {
t.Fatalf("verifyExternalUpload() error = %v", err)
}
}
func TestVerifyExternalUploadRejectsBadSignature(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil)
req.Header.Set("X-HFB-Timestamp", strconvFormatInt(time.Now().Unix()))
req.Header.Set("X-HFB-Signature", "bad-signature")
c.Request = req
handler := &Handler{externalUploadSecret: "test-upload-secret"}
if err := handler.verifyExternalUpload(c, []byte(`{}`)); err != errExternalUploadSignature {
t.Fatalf("verifyExternalUpload() error = %v, want errExternalUploadSignature", err)
}
}
func TestVerifyExternalUploadRejectsStaleTimestamp(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{}`)
secret := "test-upload-secret"
timestamp := time.Now().Add(-10 * time.Minute).Unix()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
req := httptest.NewRequest("POST", "/api/open/listing-uploads", nil)
req.Header.Set("X-HFB-Timestamp", strconvFormatInt(timestamp))
req.Header.Set("X-HFB-Signature", signExternalUploadForTest(secret, timestamp, body))
c.Request = req
handler := &Handler{externalUploadSecret: secret}
if err := handler.verifyExternalUpload(c, body); err != errExternalUploadTimestamp {
t.Fatalf("verifyExternalUpload() error = %v, want errExternalUploadTimestamp", err)
}
}
func TestExternalUploadIPAllowed(t *testing.T) {
tests := []struct {
name string
ip string
allowed []string
want bool
}{
{name: "empty whitelist", ip: "203.0.113.10", allowed: nil, want: true},
{name: "exact match", ip: "203.0.113.10", allowed: []string{"203.0.113.10"}, want: true},
{name: "cidr match", ip: "10.1.2.3", allowed: []string{"10.0.0.0/8"}, want: true},
{name: "blocked", ip: "203.0.113.10", allowed: []string{"198.51.100.0/24"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := externalUploadIPAllowed(tt.ip, tt.allowed); got != tt.want {
t.Fatalf("externalUploadIPAllowed() = %v, want %v", got, tt.want)
}
})
}
}
func signExternalUploadForTest(secret string, timestamp int64, body []byte) string {
ts := strconvFormatInt(timestamp)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte("."))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
func strconvFormatInt(value int64) string {
return strconv.FormatInt(value, 10)
}