You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by li...@apache.org on 2026/08/28 06:31:30 UTC
(answer) branch fix/2.0.3/user updated (61ff3ea1 -> 76739fcf)
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a change to branch fix/2.0.3/user
in repository https://gitbox.apache.org/repos/asf/answer.git
from 61ff3ea1 fix: hide hidden questions from public answer profiles
new a52cc0ae fix(security): enforce personal comment visibility
new 41a9d887 fix(security): restrict similar question visibility
new 372f0776 fix(security): revoke stale visit tokens
new 76739fcf fix(security): enforce mutation visibility checks
The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
internal/base/middleware/visit_img_auth_test.go | 155 +++++++++++++++++++++
internal/controller/answer_controller.go | 3 +-
internal/controller/comment_controller.go | 9 +-
internal/controller/question_controller.go | 12 +-
internal/controller/report_controller.go | 3 +-
internal/controller/vote_controller.go | 6 +-
internal/repo/auth/auth.go | 16 +++
internal/schema/answer_schema.go | 23 +--
internal/schema/comment_schema.go | 7 +-
internal/schema/report_schema.go | 7 +-
internal/schema/vote_schema.go | 11 +-
internal/service/auth/auth.go | 7 +-
internal/service/comment/comment_service.go | 47 ++++---
.../comment/comment_service_visibility_test.go | 95 +++++++++++++
internal/service/content/answer_service.go | 8 ++
internal/service/content/question_service.go | 16 ++-
.../content/question_service_visibility_test.go | 75 ++++++++++
internal/service/content/vote_service.go | 6 +
internal/service/report/report_service.go | 3 +
internal/service/user_admin/user_backyard.go | 1 +
20 files changed, 460 insertions(+), 50 deletions(-)
create mode 100644 internal/base/middleware/visit_img_auth_test.go
create mode 100644 internal/service/comment/comment_service_visibility_test.go
(answer) 01/04: fix(security): enforce personal comment visibility
Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a commit to branch fix/2.0.3/user
in repository https://gitbox.apache.org/repos/asf/answer.git
commit a52cc0aeb2b071ad73fc918c75a9b799b13352a2
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Aug 25 16:15:06 2026 +0800
fix(security): enforce personal comment visibility
---
internal/controller/comment_controller.go | 6 +-
internal/schema/comment_schema.go | 4 +-
internal/service/comment/comment_service.go | 44 ++++++----
.../comment/comment_service_visibility_test.go | 95 ++++++++++++++++++++++
4 files changed, 129 insertions(+), 20 deletions(-)
diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go
index b9beead9..e0f18a8a 100644
--- a/internal/controller/comment_controller.go
+++ b/internal/controller/comment_controller.go
@@ -280,7 +280,11 @@ func (cc *CommentController) GetCommentPersonalWithPage(ctx *gin.Context) {
return
}
- req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+ req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
+ if len(req.Username) == 0 {
+ req.UserID = req.LoginUserID
+ }
+ req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
resp, err := cc.commentService.GetCommentPersonalWithPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go
index a9c8a21a..8297c880 100644
--- a/internal/schema/comment_schema.go
+++ b/internal/schema/comment_schema.go
@@ -232,7 +232,9 @@ type GetCommentPersonalWithPageReq struct {
// username
Username string `validate:"omitempty,gt=0,lte=100" form:"username"`
// user id
- UserID string `json:"-"`
+ UserID string `json:"-"`
+ LoginUserID string `json:"-"`
+ IsAdminModerator bool `json:"-"`
}
// GetCommentPersonalWithPageResp comment response
diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go
index 0decd584..455ab864 100644
--- a/internal/service/comment/comment_service.go
+++ b/internal/service/comment/comment_service.go
@@ -539,32 +539,40 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s
}
resp := make([]*schema.GetCommentPersonalWithPageResp, 0)
for _, comment := range commentList {
+ if len(comment.ObjectID) == 0 {
+ continue
+ }
+ objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID)
+ if err != nil {
+ log.Error(err)
+ continue
+ }
+ if !canViewPersonalComment(objInfo, req.LoginUserID, req.IsAdminModerator) {
+ continue
+ }
commentResp := &schema.GetCommentPersonalWithPageResp{
- CommentID: comment.ID,
- CreatedAt: comment.CreatedAt.Unix(),
- ObjectID: comment.ObjectID,
- Content: comment.ParsedText, // todo trim
+ CommentID: comment.ID,
+ CreatedAt: comment.CreatedAt.Unix(),
+ ObjectID: comment.ObjectID,
+ Content: comment.ParsedText, // todo trim
+ ObjectType: objInfo.ObjectType,
+ Title: objInfo.Title,
+ UrlTitle: htmltext.UrlTitle(objInfo.Title),
+ QuestionID: objInfo.QuestionID,
+ AnswerID: objInfo.AnswerID,
}
- if len(comment.ObjectID) > 0 {
- objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID)
- if err != nil {
- log.Error(err)
- } else {
- commentResp.ObjectType = objInfo.ObjectType
- commentResp.Title = objInfo.Title
- commentResp.UrlTitle = htmltext.UrlTitle(objInfo.Title)
- commentResp.QuestionID = objInfo.QuestionID
- commentResp.AnswerID = objInfo.AnswerID
- if objInfo.QuestionStatus == entity.QuestionStatusDeleted {
- commentResp.Title = "Deleted question"
- }
- }
+ if objInfo.QuestionStatus == entity.QuestionStatusDeleted {
+ commentResp.Title = "Deleted question"
}
resp = append(resp, commentResp)
}
return pager.NewPageModel(total, resp), nil
}
+func canViewPersonalComment(objInfo *schema.SimpleObjectInfo, userID string, isAdminModerator bool) bool {
+ return objInfo.CheckVisibility(userID, isAdminModerator) == nil
+}
+
func (cs *CommentService) notificationQuestionComment(ctx context.Context, questionUserID,
questionID, questionTitle, commentID, commentUserID, commentSummary string) {
if questionUserID == commentUserID {
diff --git a/internal/service/comment/comment_service_visibility_test.go b/internal/service/comment/comment_service_visibility_test.go
new file mode 100644
index 00000000..69be2a96
--- /dev/null
+++ b/internal/service/comment/comment_service_visibility_test.go
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package comment
+
+import (
+ "testing"
+
+ "github.com/apache/answer/internal/base/constant"
+ "github.com/apache/answer/internal/entity"
+ "github.com/apache/answer/internal/schema"
+)
+
+func TestCanViewPersonalComment(t *testing.T) {
+ testCases := []struct {
+ name string
+ status int
+ show int
+ viewer string
+ isAdmin bool
+ expected bool
+ }{
+ {"anonymous can view public question", entity.QuestionStatusAvailable, entity.QuestionShow, "", false, true},
+ {"anonymous cannot view hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "", false, false},
+ {"anonymous cannot view pending question", entity.QuestionStatusPending, entity.QuestionShow, "", false, false},
+ {"anonymous cannot view deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "", false, false},
+ {"question owner can view hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "question-owner", false, true},
+ {"moderator can view deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "", true, true},
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ objInfo := &schema.SimpleObjectInfo{
+ ObjectType: constant.QuestionObjectType,
+ QuestionCreatorUserID: "question-owner",
+ QuestionStatus: testCase.status,
+ QuestionShow: testCase.show,
+ }
+ if got := canViewPersonalComment(objInfo, testCase.viewer, testCase.isAdmin); got != testCase.expected {
+ t.Fatalf("canViewPersonalComment() = %v, want %v", got, testCase.expected)
+ }
+ })
+ }
+}
+
+func TestCanViewPersonalCommentOnAnswer(t *testing.T) {
+ testCases := []struct {
+ name string
+ viewer string
+ isAdmin bool
+ info *schema.SimpleObjectInfo
+ expected bool
+ }{
+ {
+ name: "anonymous cannot view answer on hidden question",
+ info: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionHide},
+ expected: false,
+ },
+ {
+ name: "question owner can view answer on hidden question",
+ viewer: "question-owner",
+ info: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionHide},
+ expected: true,
+ },
+ {
+ name: "anonymous cannot view pending answer",
+ info: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusPending, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionShow},
+ expected: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ if got := canViewPersonalComment(testCase.info, testCase.viewer, testCase.isAdmin); got != testCase.expected {
+ t.Fatalf("canViewPersonalComment() = %v, want %v", got, testCase.expected)
+ }
+ })
+ }
+}
(answer) 04/04: fix(security): enforce mutation visibility checks
Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a commit to branch fix/2.0.3/user
in repository https://gitbox.apache.org/repos/asf/answer.git
commit 76739fcfd790dbc79f7df19d7294f26015de4ea1
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Wed Aug 26 11:04:20 2026 +0800
fix(security): enforce mutation visibility checks
---
internal/controller/answer_controller.go | 3 ++-
internal/controller/comment_controller.go | 3 ++-
internal/controller/report_controller.go | 3 ++-
internal/controller/vote_controller.go | 6 ++++--
internal/schema/answer_schema.go | 23 ++++++++++++-----------
internal/schema/comment_schema.go | 3 ++-
internal/schema/report_schema.go | 7 ++++---
internal/schema/vote_schema.go | 11 ++++++-----
internal/service/comment/comment_service.go | 3 +++
internal/service/content/answer_service.go | 8 ++++++++
internal/service/content/vote_service.go | 6 ++++++
internal/service/report/report_service.go | 3 +++
internal/service/user_admin/user_backyard.go | 1 +
13 files changed, 55 insertions(+), 25 deletions(-)
diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go
index 6e16c6a8..58e55c11 100644
--- a/internal/controller/answer_controller.go
+++ b/internal/controller/answer_controller.go
@@ -208,6 +208,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) {
}()
req.QuestionID = uid.DeShortID(req.QuestionID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+ req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
@@ -220,7 +221,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) {
}
linkUrlLimitUser := canList[2]
- isAdmin := middleware.GetUserIsAdminModerator(ctx)
+ isAdmin := req.IsAdminModerator
if !isAdmin || !linkUrlLimitUser {
captchaPass := ac.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionAnswer, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go
index e0f18a8a..4bdd8ac2 100644
--- a/internal/controller/comment_controller.go
+++ b/internal/controller/comment_controller.go
@@ -88,6 +88,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) {
}()
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+ req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentAdd,
@@ -100,7 +101,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) {
return
}
linkUrlLimitUser := canList[3]
- isAdmin := middleware.GetUserIsAdminModerator(ctx)
+ isAdmin := req.IsAdminModerator
if !isAdmin || !linkUrlLimitUser {
captchaPass := cc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionComment, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
diff --git a/internal/controller/report_controller.go b/internal/controller/report_controller.go
index 13b4c095..53b298ed 100644
--- a/internal/controller/report_controller.go
+++ b/internal/controller/report_controller.go
@@ -73,7 +73,8 @@ func (rc *ReportController) AddReport(ctx *gin.Context) {
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
- isAdmin := middleware.GetUserIsAdminModerator(ctx)
+ req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
+ isAdmin := req.IsAdminModerator
if !isAdmin {
captchaPass := rc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionReport, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
diff --git a/internal/controller/vote_controller.go b/internal/controller/vote_controller.go
index 30279667..10ce42a7 100644
--- a/internal/controller/vote_controller.go
+++ b/internal/controller/vote_controller.go
@@ -72,6 +72,7 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) {
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+ req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true)
if err != nil {
@@ -85,7 +86,7 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) {
return
}
- isAdmin := middleware.GetUserIsAdminModerator(ctx)
+ isAdmin := req.IsAdminModerator
if !isAdmin {
captchaPass := vc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionVote, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
@@ -126,7 +127,8 @@ func (vc *VoteController) VoteDown(ctx *gin.Context) {
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
- isAdmin := middleware.GetUserIsAdminModerator(ctx)
+ req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
+ isAdmin := req.IsAdminModerator
can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false)
if err != nil {
diff --git a/internal/schema/answer_schema.go b/internal/schema/answer_schema.go
index bf80c56e..e81b8630 100644
--- a/internal/schema/answer_schema.go
+++ b/internal/schema/answer_schema.go
@@ -47,17 +47,18 @@ const (
)
type AnswerAddReq struct {
- QuestionID string `json:"question_id"`
- Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
- HTML string `json:"-"`
- UserID string `json:"-"`
- CanEdit bool `json:"-"`
- CanDelete bool `json:"-"`
- CanRecover bool `json:"-"`
- CaptchaID string `json:"captcha_id"`
- CaptchaCode string `json:"captcha_code"`
- IP string `json:"-"`
- UserAgent string `json:"-"`
+ QuestionID string `json:"question_id"`
+ Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
+ HTML string `json:"-"`
+ UserID string `json:"-"`
+ IsAdminModerator bool `json:"-"`
+ CanEdit bool `json:"-"`
+ CanDelete bool `json:"-"`
+ CanRecover bool `json:"-"`
+ CaptchaID string `json:"captcha_id"`
+ CaptchaCode string `json:"captcha_code"`
+ IP string `json:"-"`
+ UserAgent string `json:"-"`
}
func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err error) {
diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go
index 8297c880..0fe5ee00 100644
--- a/internal/schema/comment_schema.go
+++ b/internal/schema/comment_schema.go
@@ -44,7 +44,8 @@ type AddCommentReq struct {
CaptchaCode string `json:"captcha_code"`
// user id
- UserID string `json:"-"`
+ UserID string `json:"-"`
+ IsAdminModerator bool `json:"-"`
// whether user can add it
CanAdd bool `json:"-"`
// whether user can edit it
diff --git a/internal/schema/report_schema.go b/internal/schema/report_schema.go
index 1f702df4..3c1e3325 100644
--- a/internal/schema/report_schema.go
+++ b/internal/schema/report_schema.go
@@ -28,9 +28,10 @@ type AddReportReq struct {
// report content
Content string `validate:"omitempty,gt=0,lte=500" json:"content"`
// user id
- UserID string `json:"-"`
- CaptchaID string `json:"captcha_id"` // captcha_id
- CaptchaCode string `json:"captcha_code"`
+ UserID string `json:"-"`
+ IsAdminModerator bool `json:"-"`
+ CaptchaID string `json:"captcha_id"` // captcha_id
+ CaptchaCode string `json:"captcha_code"`
}
// GetReportListReq get report list all request
diff --git a/internal/schema/vote_schema.go b/internal/schema/vote_schema.go
index e82adcc2..15a54794 100644
--- a/internal/schema/vote_schema.go
+++ b/internal/schema/vote_schema.go
@@ -20,11 +20,12 @@
package schema
type VoteReq struct {
- ObjectID string `validate:"required" json:"object_id"`
- IsCancel bool `validate:"omitempty" json:"is_cancel"`
- CaptchaID string `json:"captcha_id"`
- CaptchaCode string `json:"captcha_code"`
- UserID string `json:"-"`
+ ObjectID string `validate:"required" json:"object_id"`
+ IsCancel bool `validate:"omitempty" json:"is_cancel"`
+ CaptchaID string `json:"captcha_id"`
+ CaptchaCode string `json:"captcha_code"`
+ UserID string `json:"-"`
+ IsAdminModerator bool `json:"-"`
}
type VoteResp struct {
diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go
index 455ab864..0cbf2028 100644
--- a/internal/service/comment/comment_service.go
+++ b/internal/service/comment/comment_service.go
@@ -144,6 +144,9 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
if objInfo.IsDeleted() {
return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted)
}
+ if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+ return nil, err
+ }
objInfo.ObjectID = uid.DeShortID(objInfo.ObjectID)
objInfo.QuestionID = uid.DeShortID(objInfo.QuestionID)
objInfo.AnswerID = uid.DeShortID(objInfo.AnswerID)
diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go
index bda7b582..e43467a5 100644
--- a/internal/service/content/answer_service.go
+++ b/internal/service/content/answer_service.go
@@ -258,6 +258,14 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) (
if !exist {
return "", errors.BadRequest(reason.QuestionNotFound)
}
+ if err := (&schema.SimpleObjectInfo{
+ ObjectType: constant.QuestionObjectType,
+ QuestionCreatorUserID: questionInfo.UserID,
+ QuestionStatus: questionInfo.Status,
+ QuestionShow: questionInfo.Show,
+ }).CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+ return "", err
+ }
if questionInfo.Status == entity.QuestionStatusClosed || questionInfo.Status == entity.QuestionStatusDeleted {
err = errors.BadRequest(reason.AnswerCannotAddByClosedQuestion)
return "", err
diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go
index 1f74769f..04565438 100644
--- a/internal/service/content/vote_service.go
+++ b/internal/service/content/vote_service.go
@@ -94,6 +94,9 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s
if objectInfo.IsDeleted() {
return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted)
}
+ if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+ return nil, err
+ }
// make object id must be decoded
objectInfo.ObjectID = req.ObjectID
@@ -145,6 +148,9 @@ func (vs *VoteService) VoteDown(ctx context.Context, req *schema.VoteReq) (resp
if objectInfo.IsDeleted() {
return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted)
}
+ if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+ return nil, err
+ }
// make object id must be decoded
objectInfo.ObjectID = req.ObjectID
diff --git a/internal/service/report/report_service.go b/internal/service/report/report_service.go
index 3edbc1b4..c57d83e9 100644
--- a/internal/service/report/report_service.go
+++ b/internal/service/report/report_service.go
@@ -99,6 +99,9 @@ func (rs *ReportService) AddReport(ctx context.Context, req *schema.AddReportReq
if objInfo.IsDeleted() {
return errors.BadRequest(reason.NewObjectAlreadyDeleted)
}
+ if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+ return err
+ }
cf, err := rs.configService.GetConfigByID(ctx, req.ReportType)
if err != nil || cf == nil {
diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go
index 072feec9..acbaf996 100644
--- a/internal/service/user_admin/user_backyard.go
+++ b/internal/service/user_admin/user_backyard.go
@@ -170,6 +170,7 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up
if err := us.revokeUserAPIKeys(ctx, userInfo.ID); err != nil {
return err
}
+ us.authService.RemoveUserAllTokens(ctx, userInfo.ID)
}
// remove all content that user created, such as question, answer, comment, etc.
(answer) 03/04: fix(security): revoke stale visit tokens
Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a commit to branch fix/2.0.3/user
in repository https://gitbox.apache.org/repos/asf/answer.git
commit 372f0776ae88603823ed57e8c808240f58ab948e
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Aug 25 17:29:24 2026 +0800
fix(security): revoke stale visit tokens
---
internal/base/middleware/visit_img_auth_test.go | 155 ++++++++++++++++++++++++
internal/repo/auth/auth.go | 16 +++
internal/service/auth/auth.go | 7 +-
3 files changed, 175 insertions(+), 3 deletions(-)
diff --git a/internal/base/middleware/visit_img_auth_test.go b/internal/base/middleware/visit_img_auth_test.go
new file mode 100644
index 00000000..7d1858ce
--- /dev/null
+++ b/internal/base/middleware/visit_img_auth_test.go
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/apache/answer/internal/base/constant"
+ "github.com/apache/answer/internal/base/data"
+ "github.com/apache/answer/internal/entity"
+ authrepo "github.com/apache/answer/internal/repo/auth"
+ authservice "github.com/apache/answer/internal/service/auth"
+ "github.com/apache/answer/internal/service/siteinfo_common"
+ "github.com/gin-gonic/gin"
+)
+
+type visitAuthTestSiteInfoRepo struct{}
+
+func (visitAuthTestSiteInfoRepo) SaveByType(context.Context, string, *entity.SiteInfo) error {
+ return nil
+}
+
+func (visitAuthTestSiteInfoRepo) GetByType(context.Context, string, ...bool) (*entity.SiteInfo, bool, error) {
+ return &entity.SiteInfo{Content: `{"login_required":true}`}, true, nil
+}
+
+func (visitAuthTestSiteInfoRepo) IsBrandingFileUsed(context.Context, string) (bool, error) {
+ return false, nil
+}
+
+func TestVisitAuthRejectsRevokedAndSuspendedSessions(t *testing.T) {
+ ctx := context.Background()
+ cache, cleanup, err := data.NewCache(&data.CacheConf{})
+ if err != nil {
+ t.Fatalf("create cache: %v", err)
+ }
+ t.Cleanup(cleanup)
+
+ repo := authrepo.NewAuthRepo(&data.Data{Cache: cache})
+ service := authservice.NewAuthService(repo, nil)
+ accessToken, visitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+ UserID: "visit-auth-user",
+ UserStatus: entity.UserStatusAvailable,
+ EmailStatus: entity.EmailStatusAvailable,
+ })
+ if err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+
+ filePath := filepath.Join(t.TempDir(), "private.txt")
+ if err := os.WriteFile(filePath, []byte("private upload"), 0o600); err != nil {
+ t.Fatalf("create private upload: %v", err)
+ }
+ serve := func(token string) *httptest.ResponseRecorder {
+ gin.SetMode(gin.TestMode)
+ engine := gin.New()
+ siteInfo := siteinfo_common.NewSiteInfoCommonService(visitAuthTestSiteInfoRepo{})
+ authMiddleware := NewAuthUserMiddleware(service, siteInfo)
+ engine.Use(authMiddleware.VisitAuth())
+ engine.StaticFile("/uploads/post/private.txt", filePath)
+ req := httptest.NewRequest(http.MethodGet, "/uploads/post/private.txt", nil)
+ req.AddCookie(&http.Cookie{Name: constant.UserVisitCookiesCacheKey, Value: token})
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, req)
+ return recorder
+ }
+
+ if response := serve(visitToken); response.Code != http.StatusOK {
+ t.Fatalf("fresh visit token returned %d, want %d", response.Code, http.StatusOK)
+ }
+
+ service.RemoveUserAllTokens(ctx, "visit-auth-user")
+ if userInfo, err := service.GetUserCacheInfo(ctx, accessToken); err != nil || userInfo != nil {
+ t.Fatalf("revoked access token remained valid: userInfo=%v err=%v", userInfo, err)
+ }
+ if response := serve(visitToken); response.Code != http.StatusFound || response.Header().Get("Location") != "/403" {
+ t.Fatalf("revoked visit token returned status=%d location=%q, want 302 /403", response.Code, response.Header().Get("Location"))
+ }
+
+ _, suspendedVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+ UserID: "suspended-visit-auth-user",
+ UserStatus: entity.UserStatusAvailable,
+ EmailStatus: entity.EmailStatusAvailable,
+ })
+ if err != nil {
+ t.Fatalf("create suspended-user session: %v", err)
+ }
+ if err := service.SetUserStatus(ctx, &entity.UserCacheInfo{
+ UserID: "suspended-visit-auth-user",
+ UserStatus: entity.UserStatusSuspended,
+ EmailStatus: entity.EmailStatusAvailable,
+ }); err != nil {
+ t.Fatalf("suspend user: %v", err)
+ }
+ if response := serve(suspendedVisitToken); response.Code != http.StatusFound || response.Header().Get("Location") != "/403" {
+ t.Fatalf("suspended visit token returned status=%d location=%q, want 302 /403", response.Code, response.Header().Get("Location"))
+ }
+}
+
+func TestRemoveTokensExceptCurrentUserRevokesOnlyOtherVisitTokens(t *testing.T) {
+ ctx := context.Background()
+ cache, cleanup, err := data.NewCache(&data.CacheConf{})
+ if err != nil {
+ t.Fatalf("create cache: %v", err)
+ }
+ t.Cleanup(cleanup)
+
+ service := authservice.NewAuthService(authrepo.NewAuthRepo(&data.Data{Cache: cache}), nil)
+ currentAccessToken, currentVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+ UserID: "multi-session-user",
+ UserStatus: entity.UserStatusAvailable,
+ EmailStatus: entity.EmailStatusAvailable,
+ })
+ if err != nil {
+ t.Fatalf("create current session: %v", err)
+ }
+ _, otherVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+ UserID: "multi-session-user",
+ UserStatus: entity.UserStatusAvailable,
+ EmailStatus: entity.EmailStatusAvailable,
+ })
+ if err != nil {
+ t.Fatalf("create other session: %v", err)
+ }
+
+ service.RemoveTokensExceptCurrentUser(ctx, "multi-session-user", currentAccessToken)
+ if !service.CheckUserVisitToken(ctx, currentVisitToken) {
+ t.Fatal("current visit token was revoked")
+ }
+ if service.CheckUserVisitToken(ctx, otherVisitToken) {
+ t.Fatal("other visit token remained valid")
+ }
+}
diff --git a/internal/repo/auth/auth.go b/internal/repo/auth/auth.go
index 597352b2..7c957d78 100644
--- a/internal/repo/auth/auth.go
+++ b/internal/repo/auth/auth.go
@@ -224,6 +224,14 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainT
if token == remainToken {
continue
}
+ userInfo, err := ar.GetUserCacheInfo(ctx, token)
+ if err != nil {
+ log.Error(err)
+ } else if userInfo != nil && len(userInfo.VisitToken) > 0 {
+ if err := ar.RemoveUserVisitCacheInfo(ctx, userInfo.VisitToken); err != nil {
+ log.Error(err)
+ }
+ }
if err := ar.RemoveUserCacheInfo(ctx, token); err != nil {
log.Error(err)
} else {
@@ -233,6 +241,14 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainT
if err := ar.RemoveUserStatus(ctx, userID); err != nil {
log.Error(err)
}
+ if remainToken != "" {
+ mapping = map[string]bool{remainToken: true}
+ content, _ := json.Marshal(mapping)
+ if err := ar.data.Cache.SetString(ctx, key, string(content), constant.UserTokenCacheTime); err != nil {
+ log.Error(err)
+ }
+ return
+ }
if err := ar.data.Cache.Del(ctx, key); err != nil {
log.Error(err)
}
diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go
index a7827c78..ac86b7ab 100644
--- a/internal/service/auth/auth.go
+++ b/internal/service/auth/auth.go
@@ -102,13 +102,14 @@ func (as *AuthService) SetUserCacheInfo(ctx context.Context, userInfo *entity.Us
func (as *AuthService) CheckUserVisitToken(ctx context.Context, visitToken string) bool {
accessToken, err := as.authRepo.GetUserVisitCacheInfo(ctx, visitToken)
- if err != nil {
+ if err != nil || len(accessToken) == 0 {
return false
}
- if len(accessToken) == 0 {
+ userInfo, err := as.GetUserCacheInfo(ctx, accessToken)
+ if err != nil || userInfo == nil {
return false
}
- return true
+ return userInfo.EmailStatus == entity.EmailStatusAvailable && userInfo.UserStatus == entity.UserStatusAvailable
}
func (as *AuthService) SetUserStatus(ctx context.Context, userInfo *entity.UserCacheInfo) (err error) {
(answer) 02/04: fix(security): restrict similar question visibility
Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a commit to branch fix/2.0.3/user
in repository https://gitbox.apache.org/repos/asf/answer.git
commit 41a9d887d9361c8b4b70c50abfbb516ec09fe918
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Aug 25 16:36:04 2026 +0800
fix(security): restrict similar question visibility
---
internal/controller/question_controller.go | 12 +++-
internal/service/content/question_service.go | 16 ++++-
.../content/question_service_visibility_test.go | 75 ++++++++++++++++++++++
3 files changed, 101 insertions(+), 2 deletions(-)
diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go
index 66d7b221..8294cdcb 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -832,7 +832,17 @@ func (qc *QuestionController) UpdateQuestionInviteUser(ctx *gin.Context) {
// @Router /answer/api/v1/question/similar [get]
func (qc *QuestionController) GetSimilarQuestions(ctx *gin.Context) {
title := ctx.Query("title")
- resp, err := qc.questionService.GetQuestionsByTitle(ctx, title)
+ userID := middleware.GetLoginUserIDFromContext(ctx)
+ canReopen, err := qc.rankService.CheckOperationPermission(ctx, userID, permission.QuestionReopen, "")
+ if err != nil {
+ handler.HandleResponse(ctx, err, nil)
+ return
+ }
+ per := schema.QuestionPermission{
+ IsAdminModerator: middleware.GetUserIsAdminModerator(ctx),
+ CanReopen: canReopen,
+ }
+ resp, err := qc.questionService.GetQuestionsByTitle(ctx, title, userID, per)
handler.HandleResponse(ctx, err, resp)
}
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index 82d6e1e0..ced0e010 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1385,7 +1385,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
}
// GetQuestionsByTitle get questions by title
-func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string) (
+func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title, userID string, per schema.QuestionPermission) (
resp []*schema.QuestionBaseInfo, err error) {
resp = make([]*schema.QuestionBaseInfo, 0)
if len(title) == 0 {
@@ -1428,6 +1428,9 @@ func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string
}
}
for _, question := range questions {
+ if !canViewSimilarQuestion(question, userID, per) {
+ continue
+ }
item := &schema.QuestionBaseInfo{}
item.ID = question.ID
item.Title = question.Title
@@ -1448,6 +1451,17 @@ func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string
return resp, nil
}
+func canViewSimilarQuestion(question *entity.Question, userID string, per schema.QuestionPermission) bool {
+ if question == nil || question.Status == entity.QuestionStatusDeleted {
+ return false
+ }
+ return checkQuestionVisibility(&schema.QuestionInfoResp{
+ UserID: question.UserID,
+ Status: question.Status,
+ Show: question.Show,
+ }, userID, per) == nil
+}
+
// SimilarQuestion
func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionPageResp, int64, error) {
question, err := qs.questioncommon.Info(ctx, questionID, loginUserID)
diff --git a/internal/service/content/question_service_visibility_test.go b/internal/service/content/question_service_visibility_test.go
index 9e10b47b..5cfeb5b7 100644
--- a/internal/service/content/question_service_visibility_test.go
+++ b/internal/service/content/question_service_visibility_test.go
@@ -20,12 +20,87 @@
package content
import (
+ "context"
"testing"
+ "github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
)
+func TestCanViewSimilarQuestionWithSQLite(t *testing.T) {
+ ctx := context.Background()
+ db, err := data.NewDB(false, &data.Database{Driver: "sqlite", Connection: ":memory:"})
+ if err != nil {
+ t.Fatalf("create SQLite database: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if err := db.Sync2(new(entity.Question)); err != nil {
+ t.Fatalf("create question table: %v", err)
+ }
+
+ questions := []*entity.Question{
+ {ID: "1", UserID: "author", Title: "public match", OriginalText: "public", ParsedText: "public", Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow},
+ {ID: "2", UserID: "author", Title: "pending match", OriginalText: "pending", ParsedText: "pending", Status: entity.QuestionStatusPending, Show: entity.QuestionShow},
+ {ID: "3", UserID: "author", Title: "hidden match", OriginalText: "hidden", ParsedText: "hidden", Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide},
+ {ID: "4", UserID: "author", Title: "deleted match", OriginalText: "deleted", ParsedText: "deleted", Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow},
+ {ID: "5", UserID: "viewer", Title: "own pending match", OriginalText: "own pending", ParsedText: "own pending", Status: entity.QuestionStatusPending, Show: entity.QuestionShow},
+ }
+ for _, question := range questions {
+ if _, err := db.Context(ctx).Insert(question); err != nil {
+ t.Fatalf("insert question %s: %v", question.ID, err)
+ }
+ }
+
+ var candidates []*entity.Question
+ if err := db.Context(ctx).Where("title like ?", "%match%").Find(&candidates); err != nil {
+ t.Fatalf("load similar-question candidates: %v", err)
+ }
+ if len(candidates) != len(questions) {
+ t.Fatalf("loaded %d candidates, want %d", len(candidates), len(questions))
+ }
+
+ visibleIDs := make(map[string]bool)
+ for _, question := range candidates {
+ if canViewSimilarQuestion(question, "viewer", schema.QuestionPermission{}) {
+ visibleIDs[question.ID] = true
+ }
+ }
+ if len(visibleIDs) != 2 || !visibleIDs["1"] || !visibleIDs["5"] {
+ t.Fatalf("visible similar questions = %v, want [1 5]", visibleIDs)
+ }
+}
+
+func TestCanViewSimilarQuestion(t *testing.T) {
+ testCases := []struct {
+ name string
+ status int
+ show int
+ author string
+ viewer string
+ per schema.QuestionPermission
+ allowed bool
+ }{
+ {"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "viewer", schema.QuestionPermission{}, true},
+ {"other user's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "viewer", schema.QuestionPermission{}, false},
+ {"author's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true},
+ {"reviewer's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true},
+ {"other user's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "viewer", schema.QuestionPermission{}, false},
+ {"author's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true},
+ {"moderator's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true},
+ {"deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, false},
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ question := &entity.Question{UserID: testCase.author, Status: testCase.status, Show: testCase.show}
+ if got := canViewSimilarQuestion(question, testCase.viewer, testCase.per); got != testCase.allowed {
+ t.Fatalf("canViewSimilarQuestion() = %t, want %t", got, testCase.allowed)
+ }
+ })
+ }
+}
+
func TestCheckQuestionVisibility(t *testing.T) {
testCases := []struct {
name string