Repository navigation
[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
Description
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
Activity