Skip to content

[sec-check] Case-study link targets from the cncf.io API are gated by an /^https:\/\// prefix test, not a host allow-list — validate-case-studies.mjs accepts any host and userinfo spoofing #500

Closed

Description

@hivecommons-hive

Security Finding

Severity: medium
Type: unsafe-pattern (missing host allow-list on third-party-sourced link targets)

scripts/collect-case-studies.mjs writes the WordPress post.link field from
https://www.cncf.io/wp-json/wp/v2 into data/case-studies.json verbatim
(url: post.link, line 69). src/components/CaseStudies/index.js:190 renders
that value as <a href={study.url} target="_blank"> — the organization name on
every row of /resources/case-studies.

The only gate between the two is scripts/validate-case-studies.mjs, and both
of its URL checks are a scheme prefix test, not a URL parse:

if (!data.sourceUrl || !/^https:\/\//.test(data.sourceUrl))        // line 16
if (!entry.url || !/^https:\/\//.test(entry.url) || urls.has(entry.url))  // line 46

/^https:\/\// accepts any host, and accepts a userinfo component that makes a
hostile host read as CNCF in a generated-JSON diff. Verified at 8c089a0:

true  https://www.cncf.io@evil.example/case-studies/acme/   -> hostname evil.example
true  https://www.cncf.io.evil.example/x                    -> hostname www.cncf.io.evil.example
true  https://evil.example/x                                -> hostname evil.example

This repository has already decided this threat model is worth gating for the
identical data path: scripts/lib/profile-image.mjs parses the URL, rejects
url.username || url.password with the comment "a userinfo component is
rejected outright: https://www.cncf.io@evil.example/a.png parses to the host
evil.example while reading as CNCF to a human skimming a generated JSON diff"
,
and holds the host to an allow-list. The case-study link path is held to a
weaker standard than the profile-image path it sits beside.

All 211 URLs in data/case-studies.json today are on www.cncf.io, so the
allow-list is a no-op for current data and only catches drift.

Impact

An upstream cncf.io WordPress compromise, a hostile/mistaken CMS editor, or a
DNS/API substitution puts an attacker-chosen https:// target behind CNCF
organization names on a CNCF-branded page, published unattended. The userinfo
form additionally defeats review: a maintainer skimming the JSON diff sees
https://www.cncf.io@... and reads "cncf.io". The published CSP
(docusaurus.config.js) sets base-uri, object-src and form-action only —
it does not constrain link targets — so nothing downstream catches this.

Scope note: the same weakness exists at scripts/validate-radar-reports.mjs:45.
It is deliberately out of scope for this issue, because open PR #496
("test: cover validate-radar-reports sourceUrl, title and entry-url checks")
already claims that validator's entry-url check. It should be filed and fixed
separately once #496 lands, so the two changes do not collide.

Recommendation

In scripts/validate-case-studies.mjs, replace both /^https:\/\// prefix
tests with a parse-and-allow-list gate matching the standard already set by
scripts/lib/profile-image.mjs:

const ALLOWED_HOST_SUFFIXES = ['cncf.io'];

// data/case-studies.json is regenerated unattended from the cncf.io WordPress
// REST API, and every url it carries is rendered as an <a href> on
// /resources/case-studies. A scheme prefix test is not enough: it accepts any
// host, and accepts a userinfo component ("https://www.cncf.io@evil.example/")
// that resolves to a hostile host while reading as CNCF in a generated diff.
function publishableUrl(value) {
  if (typeof value !== 'string') return null;
  let url;
  try {
    url = new URL(value.trim());
  } catch {
    return null;
  }
  if (url.protocol !== 'https:') return null;
  if (url.username || url.password) return null;
  const host = url.hostname.toLowerCase();
  const allowed = ALLOWED_HOST_SUFFIXES.some(
    (domain) => host === domain || host.endsWith(`.${domain}`),
  );
  return allowed ? url : null;
}

…and fail the entry when publishableUrl(entry.url) returns null, with a
message that names the rejected host so a maintainer can act on it. Keep the
existing missing or duplicate url / sourceUrl must be an https URL wording
for the cases those already describe, so existing assertions in
tests/validate-case-studies.test.mjs continue to hold.

A hold-gated PR implementing exactly this follows.


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