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