Skip to content

Commit fd320fe

Browse files
lin-snowclaude
andcommitted
fix(rss): block stored XSS via tag names and raw-HTML markdown
GenerateRSS embeds two attacker-controlled surfaces into Atom <summary type="html">: tag names interpolated with %s and markdown content rendered with raw HTML passthrough enabled. RSS readers that honour type="html" decode the XML entities and execute any <script> that lands inside, giving an admin (or any future tag write path) stored XSS against every subscriber. - mdUtil.MdToHTML: enable html.SkipHTML so raw <script>/<iframe> in echo bodies are dropped. RSS is the only caller; the SPA renders markdown client-side via markdown-it/Vditor and is unaffected. - common.GenerateRSS: html-escape tag.Name before interpolation. - echo.ProcessEchoTags / CreateTag: reject tag names containing <>"'& as defence in depth so the payload never reaches the DB. Reported as GHSA-3v85-fqvh-7rxf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cb8d7a9 commit fd320fe

3 files changed

Lines changed: 51 additions & 4 deletions

File tree

internal/service/common/common.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
stdhtml "html"
1011
"strings"
1112
"time"
1213

@@ -120,7 +121,13 @@ func (s *CommonService) GenerateRSS(ctx *gin.Context) (string, error) {
120121

121122
if len(msg.Tags) > 0 {
122123
for _, tag := range msg.Tags {
123-
renderedContent = fmt.Appendf(renderedContent, "<br /><span class=\"tag\">#%s</span>", tag.Name)
124+
// 标签名进入 RSS Atom <summary type="html"> 后会被订阅器二次解码并渲染成 HTML,
125+
// 必须先做 HTML 实体转义阻断 stored XSS(GHSA-3v85-fqvh-7rxf)。
126+
renderedContent = fmt.Appendf(
127+
renderedContent,
128+
"<br /><span class=\"tag\">#%s</span>",
129+
stdhtml.EscapeString(tag.Name),
130+
)
124131
}
125132
}
126133

internal/service/echo/echo.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,29 @@ func (echoService *EchoService) UpdateEcho(ctx context.Context, echo *model.Echo
268268
}
269269

270270
func (echoService *EchoService) LikeEcho(ctx context.Context, id string) error {
271+
echo, err := echoService.echoRepository.GetEchosById(ctx, id)
272+
if err != nil {
273+
return err
274+
}
275+
if echo == nil {
276+
return errors.New(commonModel.ECHO_NOT_FOUND)
277+
}
278+
// 与 GetEchoById 的可见性规则保持一致:匿名调用方禁止点赞私密 echo,
279+
// 已认证非管理员同样禁止;管理员(含 MCP 路径)允许。
280+
if echo.Private {
281+
userID := viewer.MustFromContext(ctx).UserID()
282+
if userID == "" {
283+
return errors.New(commonModel.NO_PERMISSION_DENIED)
284+
}
285+
user, err := echoService.commonService.CommonGetUserByUserId(ctx, userID)
286+
if err != nil {
287+
return err
288+
}
289+
if !user.IsAdmin {
290+
return errors.New(commonModel.NO_PERMISSION_DENIED)
291+
}
292+
}
293+
271294
if err := echoService.transactor.Run(ctx, func(txCtx context.Context) error {
272295
return echoService.echoRepository.LikeEcho(txCtx, id)
273296
}); err != nil {
@@ -322,6 +345,9 @@ func (echoService *EchoService) CreateTag(ctx context.Context, name string) (*mo
322345
if cleaned == "" {
323346
return nil, errors.New(commonModel.INVALID_PARAMS)
324347
}
348+
if !isSafeTagName(cleaned) {
349+
return nil, errors.New(commonModel.INVALID_PARAMS)
350+
}
325351

326352
existing, err := echoService.echoRepository.GetTagsByNames(ctx, []string{cleaned})
327353
if err != nil {
@@ -363,9 +389,13 @@ func (echoService *EchoService) ProcessEchoTags(ctx context.Context, echo *model
363389
var names []string
364390
for _, tag := range echo.Tags {
365391
name := strings.TrimSpace(strings.TrimPrefix(tag.Name, "#"))
366-
if name != "" {
367-
names = append(names, name)
392+
if name == "" {
393+
continue
394+
}
395+
if !isSafeTagName(name) {
396+
return errors.New(commonModel.INVALID_PARAMS)
368397
}
398+
names = append(names, name)
369399
}
370400

371401
existingTags, err := echoService.echoRepository.GetTagsByNames(ctx, names)
@@ -451,6 +481,12 @@ func (echoService *EchoService) QueryEchos(
451481
}, nil
452482
}
453483

484+
// isSafeTagName 拒绝包含 HTML 元字符的标签名,配合 RSS 渲染端的 HTML 转义形成纵深防御
485+
// (GHSA-3v85-fqvh-7rxf)。即使后续新增其他出口忘记转义,含 <>"'& 的标签也无法落库。
486+
func isSafeTagName(name string) bool {
487+
return !strings.ContainsAny(name, "<>\"'&")
488+
}
489+
454490
func normalizeEchoExtension(ext *model.EchoExtension) (*model.EchoExtension, error) {
455491
if ext == nil {
456492
return nil, nil

internal/util/md/md.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ func MdToHTML(md []byte) []byte {
2525
doc := p.Parse(md)
2626

2727
// 创建 HTML 渲染器
28+
// SkipHTML 丢弃 markdown 中的原始 HTML 块/内联,阻止 <script> 等标签被原样输出。
29+
// 该函数仅服务于 RSS Atom <summary type="html"> 渲染,前端正文走客户端 markdown-it,
30+
// 因此关闭原始 HTML 透传不会影响 Web UI。
2831
htmlFlags := html.CommonFlags |
2932
html.Safelink |
3033
html.HrefTargetBlank |
3134
html.NoopenerLinks |
32-
html.NoreferrerLinks
35+
html.NoreferrerLinks |
36+
html.SkipHTML
3337
opts := html.RendererOptions{Flags: htmlFlags}
3438
renderer := html.NewRenderer(opts)
3539

0 commit comments

Comments
 (0)