Repository navigation
[sec-check] validate-awards.mjs gates four rendered href fields with an /^https:\/\// prefix test, accepting userinfo host-disguise URLs #507
Description
Security Finding
Severity: low
Type: unsafe-pattern (defeated link-target validation)
scripts/validate-awards.mjs is the only automated gate on the four
visitor-facing link targets in data/awards.json. It gates them with a raw
string prefix test rather than by parsing the URL:
if (!data.verifiedAgainst || !/^https:\/\//.test(data.verifiedAgainst))
...
for (const field of ['announcementUrl', 'caseStudyUrl', 'talkUrl']) {
if (entry[field] && !/^https:\/\//.test(entry[field]))/^https:\/\// only asserts that the string begins with https://. It never
establishes that the value is a parseable URL, and it never looks at the
authority, so it accepts values whose visible prefix and real host disagree:
value prefix test real hostname
"https://cncf.io@evil.example/phish" passes evil.example (userinfo=cncf.io)
"https://user:pass@evil.example/" passes evil.example
"https://" passes unparseable
"https:// evil.example" passes unparseable
These fields are rendered directly as <a href>:
src/components/AwardsTimeline/index.js:23(primaryUrl),:46
(announcementUrl),:51(caseStudyUrl),:56(talkUrl),:87
(verifiedAgainst)src/components/MemberDirectory/index.js:196,:205,:214
This repository already has the correct pattern in two places, so this is an
outlier rather than a missing convention:
scripts/validate-launch-metrics.mjs:9checkUrl()parses withnew URL()
and checksparsed.protocol !== 'https:'.src/lib/profile-links.mjswebsiteUrl()parses, enforces the protocol
allow-list, and explicitly rejects userinfo with the comment "A userinfo
component is only ever used here to disguise the real host."
validate:awards already runs in deploy-gh-pages.yml and
import-architectures.yml, so tightening this check is enforced immediately —
no workflow change is needed.
Impact
A link in data/awards.json can point at an attacker-controlled host while
reading as a cncf.io link to anyone skimming the diff — https://cncf.io@evil.example/phish
is the canonical form. The awards timeline and member directory then publish it
as a clickable outbound link on the CNCF end-user site, lending CNCF's domain
reputation to the destination. Unparseable values such as https:// also pass
the gate and ship as dead links.
Severity is low because data/awards.json is repository data that reaches
main through human PR review. The finding is that the automated gate which
exists to catch exactly this class of value does not catch it, so the only
defence is a reviewer noticing an @ in a URL.
Recommendation
Replace both prefix tests in scripts/validate-awards.mjs with a URL-parsing
helper, mirroring checkUrl() in scripts/validate-launch-metrics.mjs and the
userinfo rule in src/lib/profile-links.mjs.
Add after the const errors = []; line:
// The four award link fields are rendered as <a href> on /awards and in the
// member directory, so they are resolved through the URL parser rather than a
// string prefix test: `/^https:\/\//` accepts "https://cncf.io@evil.example",
// whose visible prefix and real host disagree, and accepts unparseable values
// such as "https://". Mirrors checkUrl() in validate-launch-metrics.mjs and
// websiteUrl() in src/lib/profile-links.mjs.
function checkHttpsUrl(path, field, value) {
let parsed;
try {
parsed = new URL(value);
} catch {
errors.push({
path,
severity: 'error',
message: `${field} must be an absolute https URL`,
});
return;
}
if (parsed.protocol !== 'https:') {
errors.push({
path,
severity: 'error',
message: `${field} must use https, got ${parsed.protocol}`,
});
return;
}
if (parsed.username || parsed.password) {
errors.push({
path,
severity: 'error',
message: `${field} must not carry a userinfo component, which only disguises the real host (${parsed.hostname})`,
});
}
}Replace the verifiedAgainst check:
if (!data.verifiedAgainst)
errors.push({
path: 'awards.json',
severity: 'error',
message:
'verifiedAgainst must be an https URL to the canonical award history source',
});
else checkHttpsUrl('awards.json', 'verifiedAgainst', data.verifiedAgainst);Replace the per-entry loop body:
for (const field of ['announcementUrl', 'caseStudyUrl', 'talkUrl']) {
if (entry[field]) checkHttpsUrl(id, field, entry[field]);
}Completion criteria
-
verifiedAgainstis validated by URL parse, not by string prefix -
announcementUrl,caseStudyUrlandtalkUrlare validated by URL parse - A value carrying a userinfo component is rejected
- An unparseable
https://-prefixed value is rejected -
tests/validate-awards.test.mjscovers the userinfo and unparseable cases -
npm run validate:awardsstill passes on the committeddata/awards.json
Scope
This is limited to scripts/validate-awards.mjs and
tests/validate-awards.test.mjs. It does not touch the equivalent prefix test
in scripts/validate-radar-reports.mjs (claimed by PR #496) or in
scripts/validate-case-studies.mjs (issue #500 / PR #501).
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 8c089a0
— hive: agent=sec-check backend=copilot model=claude-opus-5
Activity