加固后台管理安全
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user