awesome-go.com static site generator renders README-derived content unescaped through text/template into three sink classes: stored XSS (CWE-79)
Author: r20z19Created Sep 8, 2026Updated Sep 8, 2026
# awesome-go.com static site generator renders README-derived content unescaped through text/template into three sink classes: stored XSS (CWE-79)
Last saved at 2026-09-08
## Asset
avelino/awesome-go — the static site generator that builds awesome-go.com (SOURCE_CODE). Affected code: `main.go` (template parsing and rendering), `pkg/markdown/convert.go` (goldmark renderer configuration), `tmpl/project.tmpl.html`, `tmpl/category-index.tmpl.html`. Audit baseline: main branch commit `2222bc3e8d6af0a969a37640909413bf259ef235` (2026-09-07, latest at audit time), Go toolchain go1.24.1, pinned dependencies goldmark v1.6.0 and goquery v1.8.1 (go.mod).
## Weakness
Improper Neutralization of Input During Web Page Generation (Cross-site Scripting) (cwe-79)
## Description
> **Version declaration:** This report targets the **avelino/awesome-go main @ `2222bc3e8d6af0a969a37640909413bf259ef235`** (2026-09-07) repository tree; every code reference below was verified line-by-line against the actual source. The findings are **code-level white-box confirmations** (closed source call chain + segment-by-segment data-flow proof + generated-artifact forensics); no live-service requests were performed in this submission — see Steps To Reproduce for the one-click reproduction path. The deployment chain is taken from the in-repo `.github/workflows/site-deploy.yaml` (runs `go run .` on push to main and publishes `out/` to awesome-go.com).
### Summary
The awesome-go.com static site generator takes the community-PR-edited `README.md` as its sole content source, yet **no stage of the content pipeline HTML-escapes attacker-controlled text**:
1. All page templates are parsed with `text/template` (not `html/template`) — `main.go:91-102` — so `{{.Title}}`, `{{.Description}}`, `{{.URL}}` placeholders emit raw bytes;
2. The README is rendered by goldmark with `html.WithUnsafe()` enabled — `pkg/markdown/convert.go:24` — letting entity-encoded inline HTML pass through; the subsequent goquery extraction (`extractCategory`, `main.go:310-356`) reads title/description via `.Text()`, which **decodes `<script>` back to raw `< >` in memory**;
3. The rendered template output is additionally round-tripped through goquery parse-then-serialize (`main.go:223-237`, `main.go:753-761`), turning the raw `` / `` in memory;
- Those values flow through text/template into `project.tmpl.html`'s `` (:8), meta tags (:9-10,13,17,22-23), `<h1>` (:110), `<p>` (:111), and into `category-index.tmpl.html` anchor text (:111/:113 `{{.Description}}`) — unescaped end to end;
- The rendered output is parsed and re-serialized by goquery before being written (`renderCategories` `main.go:223-237`, `renderProjects` `main.go:753-761`): the raw `<script>`/`<img>` text injected by text/template is parsed into real element nodes and re-serialized as elements — **executable in the final HTML by construction**.
Generated-artifact comparison (archived local build output in `workdir/evidence/cwe79/`):
```
out/poc-xss-category/poc-repo-poc-owner-github/index.html:
<h1><script>alert(1)</script></h1>
<p><script>alert(1)</script> - <img src="x" onerror="alert(2)"/> ...</p>
```
### Finding B: JSON-LD string-context escape + meta attribute escape on category pages
**Files:** `tmpl/category-index.tmpl.html:41-70,9`
```html
<script type="application/ld+json">
{ ... "description": "{{.Description}}", ... } <!-- :46, no jsonEscape -->
</script>
```
Segment-by-segment: the `project.tmpl.html` JSON-LD block escapes `.Title`/`.Description` with `jsonEscape` (:45-46,68,74), while the analogous block in `category-index.tmpl.html` (:41-70) interpolates raw values. A category description containing `","` closes the JSON string and injects a new member: the fragment `Benign","pocInjected":"jsonld-escape-confirmed` turns the generated ld+json into `"description": "Benign","pocInjected":"jsonld-escape-confirmed",` — the injected block **still parses as JSON** (evidence `sink2_json_parse_ok.txt`: parse result `true`, injected member `pocInjected` = `jsonld-escape-confirmed`), letting the attacker tamper with structured metadata consumed by search engines and crawlers. The same raw value also breaks out of the `<meta name="description" content="{{.Description}} ...">` attribute context (:9) (evidence `benign-vs-poc-site-diff.patch`).
### Finding C: `javascript:` scheme URLs flow verbatim into the utm-suffixed href sink
**Files:** `tmpl/category-index.tmpl.html:113`, `main.go:463-519`
```html
<a href="{{.URL}}?utm_campaign=awesomego&utm_medium=referral&utm_source=awesomego" rel="noopener nofollow">{{.Description}}</a>
```
Segment-by-segment: `buildProjects` (`main.go:464-519`) calls `parseRepoURL` only for github/gitlab links (:469) and `continue`s on everything else — **no scheme validation, rewriting, or blocking**; a `javascript:` scheme in `Link.URL` therefore reaches the :113 href sink verbatim. The payload `- [Utm Bypass Link](javascript:alert(3)//)` generates:
```
<a href="javascript:alert(3)//?utm_campaign=awesomego&utm_medium=referral&utm_source=awesomego" rel="noopener nofollow">
```
The entry text carries the trailing `//`, which JS-comments out the appended utm query; a visitor click executes `alert(3)`.
### Differential vs. the developers' own partial mitigation (why the defect stands)
`project.tmpl.html:45-46,68,74` uses `{{jsonEscape .Title}}` / `{{jsonEscape .Description}}` inside the JSON-LD block, producing `\u003cscript\u003e...` that is safe in JSON context; but **the same values** stay raw in h1 (:110), p (:111), `<title>` (:8), and meta (:9-23), and the category template never uses the helper at all. The mitigation is incomplete and discontinuous, `text/template` performs no HTML-context escaping, and the defect follows.
### Versions Verified
| Version | Template engine | goldmark config | Sink reachability |
| --- | --- | --- | --- |
| avelino/awesome-go main @ `2222bc3e8d6af0a969a37640909413bf259ef235` (2026-09-07, audited tree) | ❌ `text/template` (`main.go:91-102`) | ❌ `html.WithUnsafe()` (`convert.go:24`) | ❌ All three sink classes reachable on project/category pages; goquery roundtrip preserves executable form |
The defect is structural to the generator (template engine choice + missing escaping + entity-decoding extraction) and affects every recent build of this pipeline; no fix commit was found in the audited tree.
---
## Steps To Reproduce
**Pre-conditions:** Attacker holds a GitHub account and can open a PR against avelino/awesome-go editing `README.md` (the normal community flow; no special privileges). A maintainer merges the PR; `site-deploy.yaml` (triggered on push to main) runs `go run .` in CI and publishes `out/` to awesome-go.com in production mode (`site-deploy.yaml:3-6,27-47`). This reproduction requires no access to any online service — the steps below close logically on a local source copy as a white-box reproduction.
**Step 1 — prepare the attack input.** In a repo copy, replace README entries with (full PoC: `workdir/poc/README.poc.md`; benign twin `workdir/poc/README.benign.md` as the diff baseline):
```markdown
- [<script>alert(1)</script>](https://github.com/poc-owner/poc-repo) - <img src=x onerror=alert(2)> entity-encoded payload
- [Benign Jsonld Link](https://github.com/poc-owner/poc-repo2) - Benign","pocInjected":"jsonld-escape-confirmed
- [Utm Bypass Link](javascript:alert(3)//) - javascript scheme payload
```
**Step 2 — run the generator.** `go run .` (Go 1.24.1; `AWESOME_SKIP_FETCH=1` skips metadata fetching, `main.go:523-525`). Or run the archived script: `bash workdir/repro/reproduce_cwe79.sh` (stages PoC/benign twin copies and produces all forensic files below).
**Step 3 — inspect generated pages (expected results).**
- `out/poc-xss-category/poc-repo-poc-owner-github/index.html` `<h1>`/`<p>` contain real `<script>alert(1)</script>` and `<img src="x" onerror="alert(2)"/>` elements (`sink1_h1.txt`, `sink1_project_header.txt`); the category-page anchor text is likewise injected (`sink1b_anchor.txt`).
- `out/poc-jsonld-category/index.html` ld+json contains `"description": "Benign","pocInjected":"jsonld-escape-confirmed",`; the block parses as JSON with the injected member (`sink2_jsonld_block.txt`, `sink2_json_parse_ok.txt`: `true`); the same value breaks the `<meta name="description" content="...">` attribute quoting.
- `out/poc-utm-bypass-category/index.html` anchor is `href="javascript:alert(3)//?utm_campaign=...&utm_source=awesomego"` (`sink3_anchor.txt`).
**Step 4 — differential confirmation.** `benign-vs-poc-site-diff.patch` contains only surgical diff lines at the three sink classes (the benign twin differs from the PoC input solely in the payload lines), ruling out template static-content noise; `readme-pr-diff.patch` is the attacker PR diff relative to the upstream README.
**Step 5 — browser-semantics cross-check.** Re-parse the `sink1_*` / `sink3_anchor` fragments under HTML5 browser parsing rules: `<h1><script>...</script></h1>` is a script execution context; a `javascript:` href executes on click; the escaped `<meta>` attribute value is re-tokenized by the browser at the attribute terminator. The archived `final-run.log` records all four assertions (SINK1 project / SINK1 category anchor / SINK2 ld+json / SINK3 href) as confirmed.
**Actual vs. expected results:** Actual — generated pages carry executable attacker markup in HTML context, and the JSON-LD structure is tampered with an injected member. Expected (secure behavior) — README-derived text should appear as character entities inside h1/p/anchor text, JSON-LD strings, and HTML attributes, producing no element or attribute breakout. The divergence is produced jointly by `text/template` (no escaping) + `.Text()` entity decoding + goquery re-serialization; the chain is closed at source level.
---
## Impact
| Aspect | Detail |
| --- | --- |
| **Attack requirement** | One PR editing README.md merged by a maintainer (the repo's normal community contribution flow); no in-account privileges, no special configuration |
| **Privilege boundary** | Attacker gains same-origin script execution on awesome-go.com; no server-side privileges involved — the injection lives in CI build artifacts (static HTML) |
| **Confidentiality** | Every visitor's browser executing attacker script on the affected project/category pages can read same-origin documents, localStorage, and session tokens, and exfiltrate them to attacker endpoints |
| **Integrity** | Injected JSON-LD members tamper with structured metadata consumed by search engines/crawlers (name, description, breadcrumb); page DOM fully rewriteable (phishing forms, redirects, keylogging) |
| **Availability** | Client-side only (forced redirects/loops degrade single-page experience); no server-side denial of service |
| **Persistence** | Stored/persistent: the payload lives in README.md and survives every push-to-main build until the payload lines are removed |
| **User interaction** | Sinks 1/2 execute on page load (no click); sink 3 requires a visitor click |
| **Severity** | Medium. CVSS 3.1 `AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N` ≈ 5.0–5.4; given awesome-go.com's traffic and the routine nature of community PRs, real-world exposure is far above typical vulnerabilities at this score |
| **CVE eligibility** | Yes. The root cause is in the awesome-go repository itself (template engine choice and missing escaping), not previously publicly disclosed; an independently assignable new defect |
| **Suggested submission channels** | ① GitHub Issue per SECURITY.md (https://github.com/avelino/awesome-go/issues/new); ② maintainer email [email protected]; ③ VulDB (https://vuldb.com/?submit, login required); ④ MITRE CNA-LR CVE ID Request (https://mitre.github.io/mitre-cve-roles/cve-id-request/, fallback) |
Additional notes:
- **Suggested fix:** ① migrate `main.go:91-102` from `text/template` to `html/template` (the code already imports `template2 "html/template"` for `template.HTML`, so the migration surface is contained); ② escape `extractCategory` `.Text()` results per output context before templating; ③ use `jsonEscape` for `.Title`/`.Description` in the category JSON-LD block; ④ add a scheme allowlist (http/https) for `Link.URL` at `buildProjects`/template level, rejecting `javascript:`/`data:` schemes.
- **Relation to dependency-rooted findings:** This report's root cause is in the repository's own code and is independent of the golang.org/x/net (CVE-2026-27136) and goldmark (CVE-2026-5160) findings; even with dependencies upgraded, the unescaped `text/template` chain stands on its own.
Source: avelino/awesome-go