Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions scripts/validate-case-studies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <a href> 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)),
);
Expand All @@ -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({
Expand All @@ -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({
Expand Down
90 changes: 90 additions & 0 deletions tests/validate-case-studies.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading