avelino/awesome-go site generator renders contributor-controlled entry text and repository topics through text/template without HTML escaping, causing stored XSS on the production site (CWE-79)
Author: r20z19Created Sep 11, 2026Updated Sep 11, 2026
# avelino/awesome-go site generator renders contributor-controlled entry text and repository topics through text/template without HTML escaping, causing stored XSS on the production site (CWE-79)
Last saved at 2026-09-11
## Asset
The static site generator embedded in the avelino/awesome-go repository (SOURCE_CODE) — `main.go`, `pkg/markdown/convert.go`, `tmpl/category-index.tmpl.html`, `tmpl/project.tmpl.html` — whose output is auto-deployed to the production site **https://awesome-go.com/** by `.github/workflows/site-deploy.yaml`.
- Repository: https://github.com/avelino/awesome-go (the core curated index of the Go ecosystem, 130k+ GitHub stars)
- Audited baseline: `main` branch HEAD commit `1ed3a46319b9e85d2fedcacc8cece2e0faf456b6` ("Remove di from the Dependency Injection section. (#6681)"). The project is community-maintained with no formal versioned releases (per its SECURITY.md: "no formal support commitments or versioned releases"), so the affected code is delineated by commit: every version of the site generator since the `text/template` rendering pipeline was introduced (including the audited HEAD) is affected.
- Deployment chain: push to `main` → `.github/workflows/site-deploy.yaml` runs `go run .` to generate `out/` → `nwtgck/actions-netlify` (`production-deploy: true`; `netlify.toml: publish = "out/"`) → production site.
## Weakness
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (cwe-79)
## Description
> **Version declaration:** The audited source is avelino/awesome-go `main` branch HEAD `1ed3a46`. All code references below were verified against the actual repository tree in this submission. This report is prepared in accordance with the platform Policy and Disclosure Guidelines. The findings are **code-level confirmed**: the source control-flow/data-flow is closed end-to-end, and poisoned site artifacts produced by the real build pipeline are attached as the white-box logic reproduction results (see Steps To Reproduce and the evidence files); no live production request testing was performed.
### Summary
The awesome-go site generator renders all category pages and project detail pages with Go's **`text/template`**, which performs no context-aware HTML escaping, using data that is fully attacker-controlled: contributor-supplied README.md entry text and repository topics fetched from the GitHub API. The key chain is: a contributor writes an HTML-entity-encoded payload in a README link text in a PR → goldmark emits it as escaped text → the goquery parsing stage **decodes the entities back to raw characters** via `.Text()` (`main.go:326/329`) → `text/template` writes those characters **verbatim** into the generated HTML. The only block labeled "Sanitize HTML" (`main.go:221-237`) merely re-serializes the document with goquery and sanitizes nothing — a false safety claim. The production site sets no CSP (`netlify.toml` defines no headers; the generated HTML contains no `Content-Security-Policy`), so the payload executes for every visitor.
### Finding A: `extractCategory` stores entity-decoded raw HTML characters into the Link struct
**File:** `main.go:310-356` (`extractCategory`), key lines `main.go:322-331`
```go
ul.Find("li").Each(func(_ int, selLi *goquery.Selection) {
selLink := selLi.Find("a")
url, _ := selLink.Attr("href") // main.go:324
link := Link{
Title: selLink.Text(), // main.go:326 — entity-decoded raw characters
// FIXME(kazhuravlev): Title contains only title but
// description contains Title + description
Description: selLi.Text(), // main.go:329 — same
URL: url,
}
links = append(links, link)
})
```
Attacker input (a README entry; any contributor able to open a PR can submit it):
```markdown
- [<img src=x onerror=alert(1)>](https://github.com/evil/v1-victim) - desc
```
goldmark renders the link text with the normal markdown→HTML escaping as `<img src=x onerror=alert(1)>` (harmless at this stage); but after goquery parses that HTML, `selLink.Text()` returns the **entity-decoded raw characters** ``, which are stored in `Link.Title` and `Link.Description`. From that point these strings are rendered by `text/template` without any escaping.
### Finding B: `text/template` writes the tainted fields verbatim into multiple HTML contexts
**File:** `main.go:18` (import `"text/template"`), `main.go:91-102` (template instantiation; the FuncMap contains only `now` and `jsonEscape`, no HTML escaping function). Template sinks:
- `tmpl/category-index.tmpl.html:111/113` — entry anchor body `{{.Description}}` (internal-link branch :111 and external-link branch :113, `{{.Description}}`);
- `tmpl/project.tmpl.html:110` — `
{{.Title}}
`; :106 breadcrumb `{{.Title}}`; :8 `{{.Title}}`; :9/:17/:22-23 meta/OG/Twitter `{{.Title}}`/`{{.Description}}`; :111 `<p>{{.Description}}</p>`. ```html <!-- tmpl/project.tmpl.html:109-112 --> <div class="project-header"> <h1>{{.Title}}</h1> <p>{{.Description}}</p> <a href="{{.URL}}" rel="nofollow noopener" target="_blank" class="repo-link"> ``` Unlike `html/template`, `text/template` performs no escaping when writing to HTML, so `<img ...>` is parsed by browsers as a real element. Note that the developer explicitly chose `text/template` at `main.go:91` even though `html/template` is already imported in the same file (`main.go:10`, used only for the `template2.HTML` conversion) — a clear engine misuse, not a capability gap. ### Finding C: the "Sanitize HTML" block is a false safety claim **File:** `main.go:221-237` (inside `renderCategories`); the same pattern appears in `main.go:753-758` (`renderProjects`) ```go // Sanitize HTML. This is not necessary, but allows to have content // of all html files in same style. { doc, err := goquery.NewDocumentFromReader(buf) ... html, err := doc.Html() ... if err := os.WriteFile(categoryIndexFilename, []byte(html), 0644); err != nil { ``` This block only re-parses and re-serializes the already-rendered HTML (to normalize formatting); it neither removes event handlers nor escapes markup in text nodes — injected live elements survive verbatim. ### Finding D (same root cause, variant): GitHub API repository topics injection, without touching the README **File:** `main.go:50` (`RepoMeta.Topics`), `main.go:603-669` (`fetchGitHubMeta`; `topics` comes from the `https://api.github.com/repos/<owner>/<repo>` response at `main.go:637` and is stored at `main.go:664`), `tmpl/project.tmpl.html:156-162` ```html {{if .Meta.Topics}} <div class="project-topics"> {{range .Meta.Topics}} <span class="topic-tag">{{.}}</span> {{end}} </div> {{end}} ``` Repository topics are set arbitrarily by the **repository owner**. An attacker only needs to get their own repository listed via the normal PR flow; afterwards they can change a topic in their own repository settings to `</span><img src=x onerror=alert(5)>`. The next CI metadata fetch (in production builds `AWESOME_SKIP_FETCH` is unset, so `fetchProjectMeta` always runs) carries the value through `RepoMeta.Topics` into `text/template` verbatim. This variant **requires no further PR** and remains effective until the repository is delisted. ### Versions Verified | Version | Entity-decode injection (.Text()) | Unescaped text/template sinks | Topics injection | Live payload in artifacts | |---|---|---|---|---| | avelino/awesome-go main @ `1ed3a46` (audited tree) | Present (`main.go:326/329`) | Present (`category-index.tmpl.html:111/113`, `project.tmpl.html:106/110/111`, etc.) | Present (`project.tmpl.html:159`) | Confirmed (see evidence files) | In artifacts built locally with the real generator, the payloads survive verbatim (evidence files `workdir/poc/env_awesome-go/out/security-tools/index.html:109`, `workdir/poc/env_awesome-go/out/security-tools/v1-victim-evil-github/index.html:104`, `workdir/poc/env_awesome-go/pocmeta_output.html:151`). Because the defect is structural (template-engine choice), the issue is expected to affect every historical version of the generator since `text/template` rendering was introduced; no fix commit was found in the audited tree. --- ## Steps To Reproduce **Pre-conditions:** An attacker with a GitHub account can open a PR modifying `README.md` in avelino/awesome-go; the workflow `.github/workflows/pr-quality-check.yaml` enables squash auto-merge for PRs passing the quality checks (:131-147 auto-merge job), and after merge `site-deploy.yaml` builds and deploys to production automatically with no manual security review. The site sets no CSP. **Step 1 — submit the malicious PR.** Add an entry to any category list in README.md: ```markdown - [<img src=x onerror=alert(1)>](https://github.com/evil/v1-victim) - desc ``` **Step 2 — merge triggers the build pipeline.** After merge, CI runs `go run .`: `renderIndex` (`main.go:407-436`) converts README.md to HTML via goldmark → goquery parses it → `extractCategory` extracts entry text with entity decoding via `.Text()` (`main.go:326/329`) → `renderCategories`/`renderProjects` render with `text/template` and re-serialize through goquery (`main.go:205-241`, `main.go:739-770`) → Netlify deploys. **Step 3 — observe the category page (anchor context).** Visit `https://awesome-go.com/<category>/`. Expected: a real `<img>` element inside the entry anchor that fires `onerror` on page load. Actual (derived from source logic; recorded artifact from the real local build at `workdir/poc/env_awesome-go/out/security-tools/index.html:109`): ```html <a href="/security-tools/v1-victim-evil-github/"><img src="x" onerror="alert(1)"/> - ... </a> ``` **Step 4 — observe the project detail page (`<h1>` context).** Visit `https://awesome-go.com/<category>/<slug>/`. Expected: a live `<img>` inside `<h1>`. Actual (`workdir/poc/env_awesome-go/out/security-tools/v1-victim-evil-github/index.html:104`): ```html <h1><img src="x" onerror="alert(1)"/></h1> ``` **Step 5 — topics variant (no further PR needed).** The owner of a listed repository sets a GitHub repository topic to `</span><img src=x onerror=alert(5)>`; the production build fetches the metadata and renders it. Actual (unit reproduction with the real template and the real FuncMap, artifact `workdir/poc/env_awesome-go/pocmeta_output.html:151`): ```html <span class="topic-tag"></span><img src=x onerror=alert(5)></span> ``` **Reproduction note:** Steps 3-5 are white-box logic reproduction results — the input → parsing → template rendering → artifact → browser-execution call chain is proven segment by segment from the source; payload survivability is asserted verbatim against artifact files produced by the real local build pipeline (verifier `workdir/poc/verify_out.py`, 8/8 checks passed; run log `workdir/poc/poc_output.txt`), and the browser execution semantics (`<img onerror>`) are deterministic HTML behavior, so no dynamic environment is required. Reproduction script: `workdir/poc/build_poc_site.sh`; sample attacker input: `workdir/poc/malicious_README.md`. --- ## Impact | Aspect | Detail | |---|---| | **Attack requirement** | One merged PR (routine for a community-curated project; anyone can submit; auto-merged once checks pass) — or, for an already-listed repository, merely changing the repository's own topics with no further PR | | **Permission boundary** | No special privileges needed; once the payload reaches production it executes for every visitor (anonymous included) — the boundary expands from "contributor" to "browser of every site visitor" | | **Confidentiality** | Attacker script can read/tamper page content within awesome-go.com and access data of that origin in the visitor's browser; the site has no login sessions, so direct credential theft is limited, but the script can exfiltrate any page data externally | | **Integrity** | Full control of page presentation: content replacement, fake announcements, redirect phishing, injected keyloggers/crypto-miners; JSON-LD structured data can be manipulated for SEO poisoning | | **Availability** | The script can break normal page use (pop-up loops, forced redirects), persistently disrupting affected pages | | **Severity** | Medium. CVSS 3.1 `AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N` ≈ **5.9** (PR:L because one merged PR is required; UI:N because a visitor is exploited merely by browsing; S:C because impact extends to the visitor's browser session). Given the site's role as the core Go-ecosystem index with heavy traffic and full-visitor exposure, the practical risk is materially higher than the score suggests | | **CVE eligibility** | Yes. Attacker-controlled input (README entry text / repository topics), a code-level root cause (text/template misuse + entity-decode chain), concrete dangerous sinks (multiple template sinks), and a clear impact boundary (all site visitors) — an independent vulnerability of the product itself (the awesome-go site generator, explicitly within the SECURITY.md scope) | | **Application methods** | ① Channel designated by SECURITY.md: https://github.com/avelino/awesome-go/issues/new (public issue; the scope explicitly covers the static site generator); ② maintainer email [email protected] (first line of MAINTAINERS, mailto:[email protected]); ③ VulDB: https://vuldb.com/?submit (login required); ④ MITRE CNA-LR CVE form: https://mitre.github.io/mitre-cve-roles/cve-id-request/ (cveform.mitre.org now 302-redirects there). GitHub Private Vulnerability Reporting was verified via the API as disabled (`{"enabled": false}`) and is therefore not listed | **Suggested fix:** Replace `text/template` at `main.go:91` with `html/template` (all site templates then get context-aware auto-escaping); sanitize the text/URL extracted by `extractCategory` against a whitelist (e.g. bluemonday); whitelist topics to `[a-z0-9-]`; add a `script-src 'self'` CSP on Netlify as defense in depth.Source: avelino/awesome-go