awesome-go.com build trusts a committable .cache/repos metadata cache and renders its fields unescaped: stored XSS via cache poisoning (CWE-79)
awesome-go.com build trusts a committable .cache/repos metadata cache and renders its fields unescaped: stored XSS via cache poisoning (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 (the cache-first fetchProjectMeta/readCachedMeta path and renderProjects), tmpl/project.tmpl.html (meta-card sinks), .github/workflows/site-deploy.yaml (cache restore and deployment). Audit baseline: main branch commit 2222bc3e8d6af0a969a37640909413bf259ef235 (2026-09-07, latest at audit time), Go toolchain go1.24.1.
Weakness
Improper Neutralization of Input During Web Page Generation (Cross-site Scripting) (cwe-79); accompanied by missing verification of data authenticity for untrusted file content (cwe-345, supporting note)
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.
Summary
The site generator maintains a per-project GitHub/GitLab API metadata cache file .cache/repos/{owner}/{repo}.json (main.go:563-565). The build-time read path is cache-first: fetchProjectMeta (main.go:522-561) calls readCachedMeta (main.go:567-587) first, and on a hit performs p.Meta = cached; continue, skipping the API fetch entirely. The only content validation applied to that file is that fetched_at parses as 2006-01-02 and is no older than 7 days (main.go:578-584) — the text fields license, language, and topics receive no integrity, signature, or content validation whatsoever.
The decisive attack condition is the deployment workflow itself: site-deploy.yaml:21-26 restores .cache/repos with actions/cache@v4 before every build, then runs go run . (:27-30) and publishes out/ to awesome-go.com (:35-47). .gitignore does not prevent a PR from explicitly adding (git add -f) files under .cache/ — a single ordinary PR can commit a forged cache file as repository content, and after the CI cache restore it lands in the build workspace at .cache/repos/ and is read as trusted state.
Those poisoned fields are then written unescaped by text/template into the project-page meta cards: tmpl/project.tmpl.html:134 ({{.Meta.License}}), :140 ({{.Meta.Language}}), :156-162 ({{range .Meta.Topics}}), and the rendered output passes through a goquery parse-then-serialize roundtrip (main.go:753-761) that turns the injected markup into real elements. The result is a stored XSS whose input source is fully independent of the README body and persists as long as the poisoned entry stays within its 7-day freshness window.
Finding A: The cache file is a PR-deliverable attack input; the read side only checks the date
Files: main.go:521-565, .github/workflows/site-deploy.yaml:21-30
// main.go:532-537 — a cache hit skips the API entirely
for _, p := range projects {
cached, err := readCachedMeta(p)
if err == nil && cached != nil {
p.Meta = cached
continue
}
// main.go:578-584 — the sole validation: a fresh date
fetchedAt, err := time.Parse("2006-01-02", meta.FetchedAt)
if err != nil {
return nil, err
}
if time.Since(fetchedAt) > 7*24*time.Hour {
return nil, fmt.Errorf("cache stale")
}
Segment-by-segment:
cacheFilePath(main.go:563-565) returns.cache/repos/{owner}/{repo}.json;readCachedMetadoes a plainos.ReadFile+json.Unmarshalwith no provenance/content validation beyond the date (no HMAC, no signature, no per-field allowlist or length constraints);- The
site-deploy.yamlcache step (path: .cache/repos,restore-keys: repo-meta-) runs beforego run .(:21-30); a PR-committed forged file is filesystem-indistinguishable from a genuine cache file; - The API fetch path (
fetchGitHubMeta,main.go:603-669) only writes fields from the API response struct (typed strings), while the poisoning path bypasses it entirely —license/language/topicscome from attacker JSON as arbitrary strings.
Finding B: Poisoned fields reach the meta-card sinks unescaped
Files: main.go:91-102 (text/template), tmpl/project.tmpl.html:132-162, main.go:739-770
<!-- tmpl/project.tmpl.html -->
{{if .Meta.License}}
<div class="meta-card">
<span class="meta-value">{{.Meta.License}}</span> <!-- :134 unescaped -->
<span class="meta-label">License</span>
</div>
{{end}}
{{if .Meta.Language}}
<div class="meta-card">
<span class="meta-value">{{.Meta.Language}}</span> <!-- :140 unescaped -->
<span class="meta-label">Language</span>
</div>
{{end}}
...
{{range .Meta.Topics}}
<span class="topic-tag">{{.}}</span> <!-- :159 unescaped -->
{{end}}
Segment-by-segment: the templates are parsed by text/template (main.go:91-102), so {{.Meta.License}} and friends perform no HTML-context escaping; renderProjects (main.go:739-770) round-trips the rendered buffer through goquery before writing (:753-761), parsing the raw <script>/<img> markup into real element nodes and re-serializing them as elements — the same "inject markup, get elements" semantics as the README path. All three fields (License/Language/Topics) are independently reachable, and Topics is a string array whose every member is a distinct sink.
Finding C: The 7-day freshness window is the only gate, and poisoning can be replayed at will
Segment-by-segment: fetched_at is just a string in attacker-controlled JSON — filling in the build date satisfies every check in readCachedMeta. actions/cache keeps .cache contents across builds, and at any time a fresh PR can re-poison any {owner}/{repo} (overwriting the genuine cache file); the repo-meta- restore-key semantics let the forged entry live indefinitely through repeated restores. The freshness check is not a content-security boundary in this design — it only rejects "stale data", never "malicious data".
Versions Verified
| Version | Cache-first read | Content validation | Sink escaping |
|---|---|---|---|
avelino/awesome-go main @ 2222bc3e8d6af0a969a37640909413bf259ef235 (2026-09-07, audited tree) |
❌ Hit skips API (main.go:533-537) |
❌ fetched_at date only (:578-584) |
❌ License/Language/Topics unescaped (project.tmpl.html:134,140,159) |
No fix commit was found in the audited tree; the defect is the combination of a missing cache-trust model and missing template escaping, and is structural.
Steps To Reproduce
Pre-conditions: Attacker holds a GitHub account and can open a PR against avelino/awesome-go (normal community flow); the PR explicitly adds a forged cache file via git add -f .cache/repos/poc-owner/poc-repo.json (.gitignore does not block explicit adds). After the merge, site-deploy.yaml restores the cache (actions/cache@v4, :21-26) and builds with go run . (:27-30). The local reproduction requires no access to any online service and closes through the following white-box steps.
Step 1 — prepare the poisoned cache file. Content (full file: workdir/evidence/cwe79-metacache/forged-cache-poc.json; benign twin forged-cache-benign.json):
{
"stars": 42,
"forks": 7,
"license": "<script>alert('meta-license')</script>",
"language": "<img src=x onerror=alert(2)>",
"topics": [
"meta-topic-poc",
"<script>alert('meta-topic1')</script>",
"<img src=x onerror=alert(4)>"
],
"last_push": "2026-09-01T00:00:00Z",
"open_issues": 3,
"archived": false,
"fetched_at": "2026-09-08"
}
The README entry itself remains fully benign (workdir/poc/README.poc-metacache.md) — proving the injection source is independent of the README body.
Step 2 — run the generator (three-arm comparison). Run go run . on a repo copy containing the cache file (no AWESOME_SKIP_FETCH, no GITHUB_TOKEN, ensuring the readCachedMeta path executes). The archived script workdir/repro/reproduce_cwe79_metacache.sh stages the three arms automatically: poc (poisoned + current date) / benign (benign fields) / stale (poisoned + expired date 2026-08-31).
Step 3 — inspect generated pages (expected results).
- poc arm: the project page
out/poc-metacache-category/poc-repo-poc-owner-github/index.htmlshows the License card as<span class="meta-value"><script>alert('meta-license')</script></span>(sink4a_license_metacard.txt), the Language card as a real<img src="x" onerror="alert(2)"/>(sink4b_language_metacard.txt), and topic tags containing real<script>/<img onerror>elements (sink4c_topics_block.txt); the build log printsFetched metadata for 0 projects (1 from cache)— the API fetch was skipped entirely; the cache content is the sole variable (sink4_cache_priority_log.txt). - benign arm: same README, benign cache fields, clean page (MIT/Go/plain topics) — ruling out README-body or template static-content influence (
benign-go-run.log;benign-vs-poc-site-diff.patchis the unified diff of the two identical-README builds). - stale arm: the same poisoned file with
fetched_at=2026-08-31is rejected ("cache stale") and the API fetch path is attempted (no result offline; no meta section rendered) — the fresh date is the sole gate (sink4_stale_log_informational.txt).
Actual vs. expected results: Actual — poisoned fields appear in the final project page as real executable elements; all six assertions in verdict.json (three sinks, cache-priority path, clean benign twin, stale rejection) hold. Expected (secure behavior) — cache contents should be integrity-checked and HTML-escaped per output context before templating; no field value may enter the page as an element. The divergence is produced jointly by "PR-deliverable cache file + date-only validation + unescaped text/template + goquery re-serialization"; the chain is closed at source level.
Impact
| Aspect | Detail |
|---|---|
| Attack requirement | One PR containing a forged .cache/repos/{owner}/{repo}.json merged by a maintainer (normal community flow); no server privileges |
| Privilege boundary | Attacker gains same-origin script execution on awesome-go.com project detail pages; the injection lives in CI build artifacts (static HTML) — no build-machine or deployment-credential access involved |
| Confidentiality | Visitors' browsers executing attacker script on affected project pages can read same-origin data (documents, localStorage, session tokens) and exfiltrate it |
| Integrity | Meta cards (Stars/License/Language/Topics) and the page DOM are fully rewriteable; phishing redirects and keylogging possible; forged fields also corrupt user-facing display data |
| Availability | Client-side single-page degradation only; no server-side denial of service |
| Persistence | The poisoned entry remains effective for every build within the 7-day freshness window (actions/cache persists .cache across builds); at any time a new PR can re-poison any repo |
| User interaction | Executes on page load (no click required) |
| 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; the input source is independent of the README, bypassing any "review README changes only" review habit — stealthier than body injection |
| CVE eligibility | Yes. The root cause is in the awesome-go repository itself (missing cache-trust model + missing template 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: ① bring cache contents inside an integrity boundary: HMAC/signature validation of
.cache/reposentries, or move the cache out of git-reachable paths (CI-only artifacts) and sanitize against a field allowlist before loading; ② enforce format constraints inreadCachedMetaforlicense/language/topics(e.g., SPDX identifiers, language allowlist, topic charset[a-z0-9-]); ③ switchproject.tmpl.html:134,140,159tohtml/templateor explicit per-context escaping; ④ long-term, migrate the whole template stack tohtml/template(aligned with the README-injection fix). - Relation to the README-injection finding: independent root causes (different input source:
.cachefile vs README body; different sinks: meta cards vs title/description/links); the two defects coexist and neither substitutes for the other.
Source: avelino/awesome-go