Skip to content

[sec-check] validate-awards.mjs gates four rendered href fields with an /^https:\/\// prefix test, accepting userinfo host-disguise URLs #507

Closed

Description

@hivecommons-hive

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:9 checkUrl() parses with new URL()
    and checks parsed.protocol !== 'https:'.
  • src/lib/profile-links.mjs websiteUrl() 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

  • verifiedAgainst is validated by URL parse, not by string prefix
  • announcementUrl, caseStudyUrl and talkUrl are validated by URL parse
  • A value carrying a userinfo component is rejected
  • An unparseable https://-prefixed value is rejected
  • tests/validate-awards.test.mjs covers the userinfo and unparseable cases
  • npm run validate:awards still passes on the committed data/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

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

    agent/securityApproved by a Hive merger/owner for auto-merge on green CIsecurityApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions