GitHub Security Advisory: GHSA-3v85-fqvh-7rxf
Package: github.com/lin-snow/Ech0 (GO)
Severity: MODERATE (CVSS 4.8)
CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), CWE-116: Improper Encoding or Escaping of Output
Vulnerable versions: < 1.4.8-0[PII_REDACTED]5519-fd320fe3e902
Patched in: 1.4.8-0[PII_REDACTED]5519-fd320fe3e902

Summary: Ech0's RSS feed renders unescaped tag names and raw-HTML markdown, stored XSS against subscribers

Details:
## Summary

The public RSS/Atom feed at `/rss` renders two attacker-controlled surfaces without HTML escaping. Tag names flow through `fmt.Appendf(renderedContent, "<br /><span class=\"tag\">#%s</span>", tag.Name)` at `internal/service/common/common.go:120`, and the Markdown renderer at `internal/util/md/md.go` does not set the `html.SkipHTML` flag, so raw HTML blocks in echo content pass through unmodified. The resulting Atom `<summary type="html">` is valid XML but contains executable `<script>` tags after the RSS reader decodes it. RSS subscribers whose readers render HTML (including many self-hosted and desktop clients) execute attacker JavaScript in the reader's origin.

## Details

Tag sink at `internal/service/common/common.go:120`:

```go
if len(msg.Tags) > 0 {
    for _, tag := range msg.Tags {
        renderedContent = fmt.Appendf(renderedContent,
            "<br /><span class=\"tag\">#%s</span>", tag.Name)
    }
}
```

`fmt.Appendf` with `%s` does not HTML-escape. Tag names come from user-supplied `EchoUpsertDto.Tags` and are persisted after `strings.TrimSpace(strings.TrimPrefix(tag.Name, "#"))` at `internal/service/echo/echo.go:326`, which strips a leading `#` and trims whitespace but does nothing about HTML metacharacters. A tag name of `</span><script>document.title='RSS-XSS-HIT'</script><span>x` breaks out of the surrounding `<span>` element and injects executable JavaScript into the RSS `summary` field.

Markdown sink at `internal/util/md/md.go`:

```go
htmlFlags := html.CommonFlags | html.Safelink | html.HrefTargetBlank |
             html.NoopenerLinks | html.NoreferrerLinks
// html.SkipHTML is NOT set
```

The `gomarkdown` library passes raw HTML through when `SkipHTML` is not set. `MdToHTML([]byte(msg.Content))` at `internal/service/common/common.go:102` produces the rendered HTML for the echo body; tag markup is appended to that output at line 120 and the combined byte slice becomes the RSS `summary` field.

The RSS feed declares `<summary type="html">`, which per Atom RFC 4287 §3.1.1.3 means the content is HTML encoded as XML. RSS readers that render HTML decode the XML entities and pass the decoded string to an HTML renderer. Any script tag survives this round-trip.

Echo creation requires admin role (`internal/service/echo/echo.go:54-56` checks `user.IsAdmin`). In a single-admin Ech0 instance this is self-attack. In a multi-admin deployment (non-owner admins promoted by the owner), one admin injects XSS into the shared RSS feed consumed by other admins, registered users, and anonymous subscribers.

Prior precedent: GHSA-69hx-63pv-f8f4 (2026-04-09) accepted stored XSS via SVG file upload, with the same "admin creates content" precondition. Cross-subscriber RSS XSS from one admin belongs to the same class.

## Proof of Concept

Default install, admin account seeds malicious tag + markdown content, anonymous subscriber fetches `/rss` and the decoded summary contains executable `<script>`:

```python
import requests, xml.etre