From cc2dff21d2f6cdf9c9bbeb836441b380190db3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E9=A3=9E?= <13781527+mfcom@user.noreply.gitee.com> Date: Sat, 30 May 2026 23:19:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E4=BF=AE=E5=A4=8D=20XSS?= =?UTF-8?q?=E3=80=81CSRF=E3=80=81OAuth=20=E8=B4=A6=E6=88=B7=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1=E4=B8=89=E4=B8=AA=E9=AB=98=E5=8D=B1=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复 ContentRender.tsx 存储型 XSS:将 innerHTML 改为 textContent,防止通过项目描述注入恶意脚本窃取 session cookie 2. 添加 CSRF 防护:Session Cookie 设置 SameSite=Lax,新增后端 CSRF 中间件校验 X-Requested-With 头,前端 axios 和 middleware 自动附加 3. 修复 OAuth 用户名冲突账户接管:冲突时拒绝登录而非禁用已有用户 --- .../common/markdown/ContentRender.tsx | 21 +++++++++++++----- frontend/lib/services/core/api-client.ts | 1 + frontend/middleware.ts | 1 + internal/apps/oauth/err.go | 7 +++--- internal/apps/oauth/utils.go | 12 +--------- internal/router/middlewares.go | 22 +++++++++++++++++++ internal/router/router.go | 7 +++--- 7 files changed, 49 insertions(+), 22 deletions(-) diff --git a/frontend/components/common/markdown/ContentRender.tsx b/frontend/components/common/markdown/ContentRender.tsx index 0c6be110..b13beee4 100644 --- a/frontend/components/common/markdown/ContentRender.tsx +++ b/frontend/components/common/markdown/ContentRender.tsx @@ -274,13 +274,24 @@ const markdownComponents: Components = { const target = e.target as HTMLImageElement; const errorDiv = document.createElement('div'); errorDiv.className = 'bg-muted border border-border rounded-lg p-4 text-center text-muted-foreground my-6'; - errorDiv.innerHTML = ` + + const iconDiv = document.createElement('div'); + iconDiv.innerHTML = ` - -
图片加载失败
-
${alt || src}
- `; + `; + + const msgDiv = document.createElement('div'); + msgDiv.className = 'text-sm'; + msgDiv.textContent = '图片加载失败'; + + const detailDiv = document.createElement('div'); + detailDiv.className = 'text-xs text-gray-400 dark:text-gray-500 mt-1'; + detailDiv.textContent = alt || src || ''; + + errorDiv.appendChild(iconDiv); + errorDiv.appendChild(msgDiv); + errorDiv.appendChild(detailDiv); target.parentNode?.replaceChild(errorDiv, target); }} /> diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index 22c98324..761af5a4 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -178,6 +178,7 @@ function showRiskBlockedDialog(riskInfo: RiskInfo): void { apiClient.interceptors.request.use( (config) => { config.withCredentials = true; + config.headers['X-Requested-With'] = 'XMLHttpRequest'; return config; }, (error) => Promise.reject(error), diff --git a/frontend/middleware.ts b/frontend/middleware.ts index ce819759..046c91e4 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -126,6 +126,7 @@ async function handleReceiveRequest(request: NextRequest, pathname: string): Pro 'User-Agent': 'CDK-Frontend-Middleware', 'Referer': request.headers.get('referer') || '', 'Origin': request.headers.get('origin') || '', + 'X-Requested-With': 'XMLHttpRequest', }, body: Object.keys(backendBody).length > 0 ? JSON.stringify(backendBody) : undefined, credentials: 'include', diff --git a/internal/apps/oauth/err.go b/internal/apps/oauth/err.go index e10d18a6..0e7e6dcd 100644 --- a/internal/apps/oauth/err.go +++ b/internal/apps/oauth/err.go @@ -25,7 +25,8 @@ package oauth const ( - UnAuthorized = "未登录" - InvalidState = "非法登录请求" - BannedAccount = "账号已被封禁" + UnAuthorized = "未登录" + InvalidState = "非法登录请求" + BannedAccount = "账号已被封禁" + UsernameConflict = "用户名冲突,请稍后重试或联系管理员" ) diff --git a/internal/apps/oauth/utils.go b/internal/apps/oauth/utils.go index 91b0a33f..8fa38c19 100644 --- a/internal/apps/oauth/utils.go +++ b/internal/apps/oauth/utils.go @@ -31,11 +31,8 @@ import ( "io" "time" - "fmt" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/db" "github.com/linux-do/cdk/internal/otel_trace" @@ -113,14 +110,7 @@ func doOAuth(ctx context.Context, code string) (*User, error) { err = db.DB(ctx).Transaction(func(tx *gorm.DB) error { var holder User if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.Id).First(&holder).Error; conflictErr == nil { - // 存在冲突 -> 将占用者改名并注销 - newParams := map[string]interface{}{ - "username": fmt.Sprintf("%s已注销: %s", holder.Username, uuid.NewString()), - "is_active": false, - } - if updateErr := tx.Model(&holder).Updates(newParams).Error; updateErr != nil { - return updateErr - } + return errors.New(UsernameConflict) } // 根据 ID 处理当前用户的 更新 或 创建 diff --git a/internal/router/middlewares.go b/internal/router/middlewares.go index 1bad4eed..3a322386 100644 --- a/internal/router/middlewares.go +++ b/internal/router/middlewares.go @@ -25,7 +25,9 @@ package router import ( + "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -36,6 +38,26 @@ import ( "go.opentelemetry.io/otel/trace" ) +// csrfMiddleware 校验状态变更请求必须携带 X-Requested-With 头, +// 防止跨站请求伪造(CSRF)。OAuth callback 和支付回调因来源为外部跳转,放行。 +func csrfMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + method := c.Request.Method + if method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH" { + path := c.Request.URL.Path + if strings.HasSuffix(path, "/oauth/callback") || strings.HasSuffix(path, "/payment/notify") { + c.Next() + return + } + if c.GetHeader("X-Requested-With") != "XMLHttpRequest" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error_msg": "CSRF 验证失败", "data": nil}) + return + } + } + c.Next() + } +} + func loggerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 初始化 Trace diff --git a/internal/router/router.go b/internal/router/router.go index 51f100e8..d63cf0d4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -28,6 +28,7 @@ import ( "context" "fmt" "log" + "net/http" "strconv" "github.com/gin-contrib/sessions" @@ -78,12 +79,12 @@ func Serve() { Domain: config.Config.App.SessionDomain, MaxAge: config.Config.App.SessionAge, HttpOnly: config.Config.App.SessionHttpOnly, - Secure: config.Config.App.SessionSecure, // 若用 HTTPS 可以设 true + Secure: config.Config.App.SessionSecure, + SameSite: http.SameSiteLaxMode, }, ) r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) - - // 补充中间件 + r.Use(csrfMiddleware()) r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware()) apiGroup := r.Group(config.Config.App.APIPrefix)