#6673·awesome-go

awesome-go.com build trusts a committable .cache/repos metadata cache and renders its fields unescaped: stored XSS via cache poisoning (CWE-79)

Author: r20z19Created Sep 8, 2026Updated Sep 8, 2026

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; readCachedMeta does a plain os.ReadFile + json.Unmarshal with no provenance/content validation beyond the date (no HMAC, no signature, no per-field allowlist or length constraints);
  • The site-deploy.yaml cache step (path: .cache/repos, restore-keys: repo-meta-) runs before go 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/topics come 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.html shows 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 prints Fetched 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.patch is the unified diff of the two identical-README builds).
  • stale arm: the same poisoned file with fetched_at=2026-08-31 is 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/repos entries, or move the cache out of git-reachable paths (CI-only artifacts) and sanitize against a field allowlist before loading; ② enforce format constraints in readCachedMeta for license/language/topics (e.g., SPDX identifiers, language allowlist, topic charset [a-z0-9-]); ③ switch project.tmpl.html:134,140,159 to html/template or explicit per-context escaping; ④ long-term, migrate the whole template stack to html/template (aligned with the README-injection fix).
  • Relation to the README-injection finding: independent root causes (different input source: .cache file vs README body; different sinks: meta cards vs title/description/links); the two defects coexist and neither substitutes for the other.