This commit is contained in:
Yvan
2026-08-18 15:04:07 +08:00
parent 10ccc005be
commit 1ef8ecb7b2
42 changed files with 3027 additions and 296 deletions
+3
View File
@@ -24,6 +24,9 @@ import (
func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
if err := engine.SetTrustedProxies(nil); err != nil && logger != nil {
logger.Error("disable trusted proxies failed", "error", err)
}
engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(logger), servermiddleware.AccessLog(runtime, logger, version), servermiddleware.CORS(runtime), servermiddleware.ErrorAudit(logger), servermiddleware.SecurityRateLimit(security))
prefix := ""
+25
View File
@@ -13,6 +13,8 @@ import (
"kra/internal/conf"
"kra/internal/server/handler"
"github.com/gin-gonic/gin"
)
func emptyHandlers() *handler.Set {
@@ -99,6 +101,29 @@ func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
}
}
func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) {
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
request.RemoteAddr = "203.0.113.10:4321"
request.Header.Set("X-Forwarded-For", "198.51.100.20")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusOK || response.Body.String() != "203.0.113.10" {
t.Fatalf("client IP = %q, status=%d", response.Body.String(), response.Code)
}
}
func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) {
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil))
if response.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func TestLocalStorageResponseHeaders(t *testing.T) {
root := t.TempDir()
for name, body := range map[string]string{"script.html": "<script>alert(1)</script>", "image.png": "png"} {
+55 -6
View File
@@ -1,6 +1,8 @@
package handler
import (
"errors"
"net/http"
"strconv"
"kra/internal/server/httpx"
@@ -15,13 +17,40 @@ type Media struct{ service *service.MediaService }
func NewMedia(service *service.MediaService) *Media { return &Media{service: service} }
func (h *Media) limitMultipartBody(c *gin.Context) int64 {
limit := h.service.MediaConfig().EffectiveMaxFileSize()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit+(1<<20))
return limit
}
func rejectMediaTooLarge(c *gin.Context, err error, message string) bool {
var tooLarge *http.MaxBytesError
if !errors.As(err, &tooLarge) {
return false
}
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, httpx.Response{Code: httpx.CodeError, Data: gin.H{}, Msg: message})
return true
}
func (h *Media) Upload(c *gin.Context) {
claims := servermiddleware.Claims(c)
header, err := c.FormFile("file")
if claims == nil || err != nil {
if claims == nil {
httpx.Fail(c, "接收文件失败")
return
}
limit := h.limitMultipartBody(c)
header, err := c.FormFile("file")
if err != nil {
if rejectMediaTooLarge(c, err, "文件超过大小上限") {
return
}
httpx.Fail(c, "接收文件失败")
return
}
if header.Size > limit {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, httpx.Response{Code: httpx.CodeError, Data: gin.H{}, Msg: "文件超过大小上限"})
return
}
opened, err := header.Open()
if err != nil {
// Let the upload service surface this as the generic upload
@@ -191,13 +220,33 @@ func (h *Media) InitUpload(c *gin.Context) {
}
func (h *Media) SaveChunk(c *gin.Context) {
claims := servermiddleware.Claims(c)
uploadID, _ := strconv.ParseUint(c.PostForm("uploadId"), 10, 64)
index, _ := strconv.Atoi(c.PostForm("chunkIndex"))
header, err := c.FormFile("chunk")
if claims == nil || err != nil {
if claims == nil {
httpx.Fail(c, "接收分片失败")
return
}
limit := h.limitMultipartBody(c)
header, err := c.FormFile("chunk")
if err != nil {
if rejectMediaTooLarge(c, err, "分片超过大小上限") {
return
}
httpx.Fail(c, "接收分片失败")
return
}
if header.Size > limit {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, httpx.Response{Code: httpx.CodeError, Data: gin.H{}, Msg: "分片超过大小上限"})
return
}
uploadID, parseErr := strconv.ParseUint(c.PostForm("uploadId"), 10, 64)
if parseErr != nil || uploadID == 0 {
httpx.Fail(c, "上传会话 ID 非法")
return
}
index, parseErr := strconv.Atoi(c.PostForm("chunkIndex"))
if parseErr != nil || index < 0 {
httpx.Fail(c, "分片序号非法")
return
}
opened, err := header.Open()
if err != nil {
httpx.Fail(c, "分片读取失败")
+10 -2
View File
@@ -34,12 +34,20 @@ func (h *Public) captchaConfig() (int, int, int) {
func (h *Public) Captcha(c *gin.Context) {
keyLong, width, height := h.captchaConfig()
security, _ := h.settings.CurrentSecurity(c.Request.Context())
security, err := h.settings.CurrentSecurity(c.Request.Context())
if err != nil || security == nil {
httpx.Fail(c, "安全服务暂不可用")
return
}
openCaptcha := true
if security != nil {
keyLong, width, height = security.KeyLong, security.ImgWidth, security.ImgHeight
ttl := time.Duration(security.CaptchaTimeout) * time.Second
failures, _ := h.settings.EnsureLoginIPCounter(c.Request.Context(), c.ClientIP(), ttl)
failures, counterErr := h.settings.EnsureLoginIPCounter(c.Request.Context(), c.ClientIP(), ttl)
if counterErr != nil {
httpx.Fail(c, "安全服务暂不可用")
return
}
openCaptcha = security.CaptchaOpen == 0 || failures > security.CaptchaOpen
}
driver := base64Captcha.NewDriverDigit(height, width, keyLong, 0.7, 80)
+11 -2
View File
@@ -1,6 +1,8 @@
package middleware
import (
"context"
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/server/httpx"
@@ -9,7 +11,12 @@ import (
"github.com/gin-gonic/gin"
)
func AccessControl(runtime *conf.Runtime, access *service.AccessControlService) gin.HandlerFunc {
type accessController interface {
Authorize(context.Context, uint, string, string) (bool, error)
ContextWithDataScope(context.Context, uint, uint) (context.Context, error)
}
func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFunc {
return func(c *gin.Context) {
claims := Claims(c)
if claims == nil {
@@ -29,7 +36,9 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService)
}
requestContext, err := access.ContextWithDataScope(c.Request.Context(), claims.AuthorityID, claims.ID)
if err != nil {
requestContext = c.Request.Context()
httpx.Write(c, httpx.CodeError, gin.H{}, "数据权限解析失败")
c.Abort()
return
}
if scope, ok := biz.DataScopeFromContext(requestContext); ok {
requestID, _ := c.Get("request_id")
+47 -10
View File
@@ -2,17 +2,22 @@ package middleware
import (
"bytes"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/server/httpx"
"github.com/gin-gonic/gin"
)
const defaultRequestBodyLimit int64 = 8 << 20
// AccessLog is the single global request/response capture point, matching
// the reference middleware ordering and making every HTTP request observable.
func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.HandlerFunc {
@@ -20,20 +25,45 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
started := time.Now()
var requestBody []byte
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
mediaUpload := multipart && isMediaUploadRoute(c.FullPath())
config := runtime.Admin()
bodyLimit := defaultRequestBodyLimit
if mediaUpload {
bodyLimit = biz.DefaultMaxMediaFileSize + (1 << 20)
if config != nil && config.Media != nil && config.Media.MaxFileSize > 0 {
bodyLimit = config.Media.MaxFileSize + (1 << 20)
}
}
bytesIn := c.Request.ContentLength
if c.Request.Body != nil && !multipart {
requestBody, _ = io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
bytesIn = int64(len(requestBody))
}
if bytesIn < 0 {
bytesIn = 0
}
maxBytes := 1 << 20
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
c.Writer = writer
c.Header("X-Kra-Version", version)
config := runtime.Admin()
requestReadFailed := c.Request.ContentLength > bodyLimit
if requestReadFailed {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.CodeError, "msg": "请求体超过大小上限"})
} else if c.Request.Body != nil && !mediaUpload {
limited := http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit)
var err error
requestBody, err = io.ReadAll(limited)
if err != nil {
requestReadFailed = true
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.CodeError, "msg": "请求体超过大小上限"})
} else {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": httpx.CodeError, "msg": "请求体读取失败"})
}
} else {
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
bytesIn = int64(len(requestBody))
}
} else if c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit)
}
if bytesIn < 0 {
bytesIn = 0
}
logLimit := 1024
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
logLimit = int(config.Zap.AccessLogMaxBytes)
@@ -46,7 +76,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
}
c.Set(ctxReqBodyKey, requestText)
c.Set(ctxRespBufferKey, &writer.body)
c.Next()
if !requestReadFailed {
c.Next()
}
if logger == nil {
return
}
@@ -86,6 +118,11 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
}
}
func isMediaUploadRoute(route string) bool {
return strings.HasSuffix(route, "/fileUploadAndDownload/upload") ||
strings.HasSuffix(route, "/mediaUpload/chunk")
}
func redactHeaders(headers map[string][]string) map[string]string {
out := make(map[string]string, len(headers))
for key, values := range headers {
@@ -0,0 +1,65 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"kra/internal/conf"
"github.com/gin-gonic/gin"
)
func TestAccessLogRejectsOversizedRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
called := false
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
engine.POST("/payload", func(c *gin.Context) { called = true })
request := httptest.NewRequest(http.MethodPost, "/payload", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusRequestEntityTooLarge || called {
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
}
}
func TestAccessLogRejectsOversizedMultipartOnOrdinaryRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
called := false
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
engine.POST("/login", func(c *gin.Context) { called = true })
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
request.ContentLength = -1
request.Header.Set("Content-Type", "multipart/form-data; boundary=test")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusRequestEntityTooLarge || called {
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
}
}
func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
called := false
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
engine.POST("/api/fileUploadAndDownload/upload", func(c *gin.Context) {
called = true
_, _ = io.Copy(io.Discard, c.Request.Body)
})
request := httptest.NewRequest(http.MethodPost, "/api/fileUploadAndDownload/upload", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
request.Header.Set("Content-Type", "multipart/form-data; boundary=test")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusOK || !called {
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
}
}
+51
View File
@@ -0,0 +1,51 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"kra/internal/biz"
"kra/internal/conf"
"github.com/gin-gonic/gin"
)
type accessControllerStub struct {
scopeErr error
}
func (*accessControllerStub) Authorize(context.Context, uint, string, string) (bool, error) {
return true, nil
}
func (s *accessControllerStub) ContextWithDataScope(ctx context.Context, authorityID, userID uint) (context.Context, error) {
if s.scopeErr != nil {
return ctx, s.scopeErr
}
return biz.NewDataScopeContext(ctx, biz.DataScope{UserID: userID, AuthorityID: authorityID, Scope: 1, All: true}), nil
}
func TestAccessControlFailsClosedWhenDataScopeResolutionFails(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) {
c.Set(claimsKey, &biz.AuthClaims{ID: 7, AuthorityID: 888})
c.Next()
})
engine.Use(AccessControl(conf.NewRuntime(nil, &conf.AdminBackend{}), &accessControllerStub{scopeErr: errors.New("database unavailable")}))
called := false
engine.GET("/protected", func(c *gin.Context) { called = true })
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/protected", nil))
if called {
t.Fatal("protected handler ran after data-scope resolution failed")
}
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "数据权限解析失败") {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
+13 -1
View File
@@ -3,6 +3,7 @@ package middleware
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -41,7 +42,18 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
requestBody = []byte("[文件]")
} else {
requestBody, _ = io.ReadAll(c.Request.Body)
limited := http.MaxBytesReader(c.Writer, c.Request.Body, defaultRequestBodyLimit)
var err error
requestBody, err = io.ReadAll(limited)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
} else {
c.AbortWithStatus(http.StatusBadRequest)
}
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
}
}
+11 -2
View File
@@ -1,6 +1,7 @@
package middleware
import (
"net/http"
"strings"
"time"
@@ -18,7 +19,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
return
}
config, err := settings.CurrentSecurity(c.Request.Context())
if err != nil || config == nil || !config.LimitEnable {
if err != nil || config == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": httpx.CodeError, "msg": "安全服务暂不可用"})
return
}
if !config.LimitEnable {
c.Next()
return
}
@@ -28,7 +33,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
}
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath()
count, cacheErr := settings.IncrementRateLimit(c.Request.Context(), key, time.Duration(window)*time.Second)
if cacheErr == nil && int(count) > config.LimitCount {
if cacheErr != nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": httpx.CodeError, "msg": "安全服务暂不可用"})
return
}
if int(count) > config.LimitCount {
c.JSON(200, gin.H{"code": httpx.CodeError, "msg": "请求太过频繁,请稍后再试"})
c.Abort()
return
+23 -5
View File
@@ -2,6 +2,7 @@ package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -26,12 +27,14 @@ func (rateLimitSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Tim
return nil
}
type rateLimitCache struct{}
type rateLimitCache struct{ err error }
func (rateLimitCache) Get(context.Context, string) (string, bool, error) { return "", false, nil }
func (rateLimitCache) Set(context.Context, string, string, time.Duration) error { return nil }
func (rateLimitCache) Delete(context.Context, string) error { return nil }
func (rateLimitCache) Increment(context.Context, string, time.Duration) (int64, error) { return 2, nil }
func (rateLimitCache) Get(context.Context, string) (string, bool, error) { return "", false, nil }
func (rateLimitCache) Set(context.Context, string, string, time.Duration) error { return nil }
func (rateLimitCache) Delete(context.Context, string) error { return nil }
func (c rateLimitCache) Increment(context.Context, string, time.Duration) (int64, error) {
return 2, c.err
}
func TestSecurityRateLimitMatchesResponseContract(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -50,3 +53,18 @@ func TestSecurityRateLimitMatchesResponseContract(t *testing.T) {
t.Fatalf("unexpected rate-limit response: %s", body)
}
}
func TestSecurityRateLimitFailsClosedWhenCacheIsUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
settings := service.NewSecurityService(biz.NewSecurityUsecase(rateLimitSecurityRepo{}, rateLimitCache{err: errors.New("cache unavailable")}, nil, nil))
engine := gin.New()
called := false
engine.Use(SecurityRateLimit(settings))
engine.POST("/base/login", func(c *gin.Context) { called = true })
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/base/login", nil))
if response.Code != http.StatusServiceUnavailable || called {
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ func RegisterAnnouncement(private, public *gin.RouterGroup, h *handler.Announcem
privateInfo.PUT("/updateInfo", h.Update)
privateInfo.GET("/findInfo", h.Find)
privateInfo.GET("/getInfoList", h.List)
privateInfo.GET("/getInfoDataSource", h.DataSource)
publicInfo := public.Group("/info")
publicInfo.GET("/getInfoDataSource", h.DataSource)
publicInfo.GET("/getInfoPublic", h.Public)
}