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:
- All page templates are parsed with
text/template (not html/template) — main.go:91-102 — so {{.Title}}, {{.Description}}, {{.URL}} placeholders emit raw bytes;
- 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;
- 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
- [<script>alert(1)</script>](https://github.com/poc-owner/poc-repo) - <img src=x onerror=alert(2)> ... 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&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 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&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):
- [<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 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.
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 commit2222bc3e8d6af0a969a37640909413bf259ef235(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
Summary
The awesome-go.com static site generator takes the community-PR-edited
README.mdas its sole content source, yet no stage of the content pipeline HTML-escapes attacker-controlled text:text/template(nothtml/template) —main.go:91-102— so{{.Title}},{{.Description}},{{.URL}}placeholders emit raw bytes;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;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.yamlregenerates 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-46appliesjsonEscapeto 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 roundtripFiles:
main.go:91-102,main.go:310-356,tmpl/project.tmpl.html:8-25,110-112,tmpl/category-index.tmpl.html:108-116Segment-by-segment:
- [<script>alert(1)</script>](https://github.com/poc-owner/poc-repo) - <img src=x onerror=alert(2)> ...passes goldmark (html.WithUnsafe(),convert.go:24) as entity-encoded inline HTML;extractCategory(main.go:326selLink.Text(),main.go:329selLi.Text()) extracts via goquery.Text()—.Text()decodes HTML entities, soLink.Title/Link.Descriptionhold raw<script>alert(1)</script>/<img src=x onerror=alert(2)>in memory;project.tmpl.html's<title>(:8), meta tags (:9-10,13,17,22-23),<h1>(:110),<p>(:111), and intocategory-index.tmpl.htmlanchor text (:111/:113{{.Description}}) — unescaped end to end;renderCategoriesmain.go:223-237,renderProjectsmain.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/):Finding B: JSON-LD string-context escape + meta attribute escape on category pages
Files:
tmpl/category-index.tmpl.html:41-70,9Segment-by-segment: the
project.tmpl.htmlJSON-LD block escapes.Title/.DescriptionwithjsonEscape(:45-46,68,74), while the analogous block incategory-index.tmpl.html(:41-70) interpolates raw values. A category description containing","closes the JSON string and injects a new member: the fragmentBenign","pocInjected":"jsonld-escape-confirmedturns the generated ld+json into"description": "Benign","pocInjected":"jsonld-escape-confirmed",— the injected block still parses as JSON (evidencesink2_json_parse_ok.txt: parse resulttrue, injected memberpocInjected=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) (evidencebenign-vs-poc-site-diff.patch).Finding C:
javascript:scheme URLs flow verbatim into the utm-suffixed href sinkFiles:
tmpl/category-index.tmpl.html:113,main.go:463-519Segment-by-segment:
buildProjects(main.go:464-519) callsparseRepoURLonly for github/gitlab links (:469) andcontinues on everything else — no scheme validation, rewriting, or blocking; ajavascript:scheme inLink.URLtherefore reaches the :113 href sink verbatim. The payload- [Utm Bypass Link](javascript:alert(3)//)generates:The entry text carries the trailing
//, which JS-comments out the appended utm query; a visitor click executesalert(3).Differential vs. the developers' own partial mitigation (why the defect stands)
project.tmpl.html:45-46,68,74uses{{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/templateperforms no HTML-context escaping, and the defect follows.Versions Verified
2222bc3e8d6af0a969a37640909413bf259ef235(2026-09-07, audited tree)text/template(main.go:91-102)html.WithUnsafe()(convert.go:24)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) runsgo run .in CI and publishesout/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 twinworkdir/poc/README.benign.mdas the diff baseline):Step 2 — run the generator.
go run .(Go 1.24.1;AWESOME_SKIP_FETCH=1skips 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.htmlld+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.htmlanchor ishref="javascript:alert(3)//?utm_campaign=...&utm_source=awesomego"(sink3_anchor.txt).Step 4 — differential confirmation.
benign-vs-poc-site-diff.patchcontains 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.patchis the attacker PR diff relative to the upstream README.Step 5 — browser-semantics cross-check. Re-parse the
sink1_*/sink3_anchorfragments under HTML5 browser parsing rules:<h1><script>...</script></h1>is a script execution context; ajavascript:href executes on click; the escaped<meta>attribute value is re-tokenized by the browser at the attribute terminator. The archivedfinal-run.logrecords 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
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 scoreAdditional notes:
main.go:91-102fromtext/templatetohtml/template(the code already importstemplate2 "html/template"fortemplate.HTML, so the migration surface is contained); ② escapeextractCategory.Text()results per output context before templating; ③ usejsonEscapefor.Title/.Descriptionin the category JSON-LD block; ④ add a scheme allowlist (http/https) forLink.URLatbuildProjects/template level, rejectingjavascript:/data:schemes.text/templatechain stands on its own.