diff --git a/scripts/validate-case-studies.mjs b/scripts/validate-case-studies.mjs index f94f1c09..1989f571 100644 --- a/scripts/validate-case-studies.mjs +++ b/scripts/validate-case-studies.mjs @@ -2,6 +2,34 @@ import { readFileSync } from 'node:fs'; import { reportAndExit } from './lib/validate-utils.mjs'; +// data/case-studies.json is regenerated from the cncf.io WordPress REST API by +// scripts/collect-case-studies.mjs, which copies each post's `link` through +// verbatim, and src/components/CaseStudies renders every one of them as an +// under a CNCF organization name. A scheme prefix test is not a gate: +// it accepts any host, and it accepts a userinfo component +// ("https://www.cncf.io@evil.example/") that resolves to a hostile host while +// reading as CNCF to a maintainer skimming a generated JSON diff. Parse the URL +// and hold the host to an allow-list, the same standard +// scripts/lib/profile-image.mjs already applies to profile images. +const ALLOWED_HOST_SUFFIXES = ['cncf.io']; + +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; +} + const data = JSON.parse( readFileSync(new URL('../data/case-studies.json', import.meta.url)), ); @@ -13,11 +41,11 @@ if (Number.isNaN(Date.parse(data.generatedAt))) severity: 'error', message: 'generatedAt must be a parseable date', }); -if (!data.sourceUrl || !/^https:\/\//.test(data.sourceUrl)) +if (!data.sourceUrl || !publishableUrl(data.sourceUrl)) errors.push({ path: 'case-studies.json', severity: 'error', - message: 'sourceUrl must be an https URL', + message: `sourceUrl must be an https URL, with no userinfo, on ${ALLOWED_HOST_SUFFIXES.join(' or ')}`, }); if (!Array.isArray(data.caseStudies) || !data.caseStudies.length) errors.push({ @@ -43,12 +71,18 @@ for (const entry of data.caseStudies || []) { severity: 'error', message: 'missing organization', }); - if (!entry.url || !/^https:\/\//.test(entry.url) || urls.has(entry.url)) + if (!entry.url || urls.has(entry.url)) errors.push({ path: String(id), severity: 'error', message: 'missing or duplicate url', }); + else if (!publishableUrl(entry.url)) + errors.push({ + path: String(id), + severity: 'error', + message: `url must be an https URL, with no userinfo, on ${ALLOWED_HOST_SUFFIXES.join(' or ')}: ${JSON.stringify(entry.url)}`, + }); urls.add(entry.url); if (!Array.isArray(entry.projects)) errors.push({ diff --git a/tests/validate-case-studies.test.mjs b/tests/validate-case-studies.test.mjs index 36b96a28..a6f5be4d 100644 --- a/tests/validate-case-studies.test.mjs +++ b/tests/validate-case-studies.test.mjs @@ -88,3 +88,93 @@ test('rejects an entry with a non-array projects field', () => { assert.equal(result.status, 1); assert.match(result.stderr, /projects must be an array/); }); + +test('rejects a sourceUrl on a host outside cncf.io', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ ...validData, sourceUrl: 'https://evil.example/case-studies/' }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /sourceUrl must be an https URL/); +}); + +test('rejects a sourceUrl that spoofs cncf.io through a userinfo component', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ + ...validData, + sourceUrl: 'https://www.cncf.io@evil.example/case-studies/', + }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /sourceUrl must be an https URL/); +}); + +test('rejects an entry url on a host outside cncf.io', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ + ...validData, + caseStudies: [{ ...validEntry, url: 'https://evil.example/acme/' }], + }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be an https URL/); +}); + +test('rejects an entry url that only prefixes an allowed host', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ + ...validData, + caseStudies: [ + { ...validEntry, url: 'https://www.cncf.io.evil.example/acme/' }, + ], + }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be an https URL/); +}); + +test('rejects an entry url that spoofs cncf.io through a userinfo component', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ + ...validData, + caseStudies: [ + { + ...validEntry, + url: 'https://www.cncf.io@evil.example/case-studies/acme/', + }, + ], + }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be an https URL/); +}); + +test('rejects a non-https entry url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ + ...validData, + caseStudies: [ + { ...validEntry, url: 'http://www.cncf.io/case-studies/acme/' }, + ], + }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be an https URL/); +}); + +test('accepts an entry url on a cncf.io subdomain', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture({ + ...validData, + caseStudies: [{ ...validEntry, url: 'https://cncf.io/case-studies/acme/' }], + }), + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Validated 1 case studies/); +});