Skip to content

awesome-go.com static site generator renders README-derived content unescaped through text/template into three sink classes: stored XSS (CWE-79) #6672

Description

@r20z19

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 &lt;script&gt; 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 <script> injected by text/template into a real DOM element that is faithfully serialized into the final HTML.

An attacker only needs to open a normal PR editing README.md (the standard contribution path for this repo); once merged, site-deploy.yaml regenerates and publishes the site, and the project/category pages of awesome-go.com execute attacker JavaScript against every visitor. The incomplete mitigation present in the source (project.tmpl.html:45-46 applies jsonEscape to Title/Description only inside the JSON-LD block) shows the authors are aware of the risk, while the same values remain raw in h1/p/meta attributes.

Finding A: Entity-encoded <script> becomes real elements after text/template + goquery roundtrip

Files: main.go:91-102, main.go:310-356, tmpl/project.tmpl.html:8-25,110-112, tmpl/category-index.tmpl.html:108-116

// main.go:91-102 — all page templates parsed with text/template, zero contextual escaping
var tpl = template.Must(
	template.New("").Funcs(template.FuncMap{
		"now":         func() string { return time.Now().Format("2006-01-02") },
		"jsonEscape":  func(s string) string { ... },
	}).ParseFS(tplFs, "tmpl/*.tmpl.html", "tmpl/*.tmpl.xml"),
)

Segment-by-segment:

  • A README entry - [&lt;script&gt;alert(1)&lt;/script&gt;](https://github.com/poc-owner/poc-repo) - &lt;img src=x onerror=alert(2)&gt; ... passes goldmark (html.WithUnsafe(), convert.go:24) as entity-encoded inline HTML; extractCategory (main.go:326 selLink.Text(), main.go:329 selLi.Text()) extracts via goquery .Text().Text() decodes HTML entities, so Link.Title / Link.Description hold raw <script>alert(1)</script> / <img src=x onerror=alert(2)> in memory;
  • Those values flow through text/template into project.tmpl.html's <title> (: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

<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

<a href="{{.URL}}?utm_campaign=awesomego&amp;utm_medium=referral&amp;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 continues 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&amp;utm_medium=referral&amp;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):

- [&lt;script&gt;alert(1)&lt;/script&gt;](https://github.com/poc-owner/poc-repo) - &lt;img src=x onerror=alert(2)&gt; 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=...&amp;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 avelinorun@gmail.com; ③ 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions