From 4e0f62d52ed3c6352e22650cd2fdd8a8416e77d1 Mon Sep 17 00:00:00 2001 From: "hivecommons-hive[bot]" Date: Fri, 25 Sep 2026 23:30:15 -0400 Subject: [PATCH] fix(security): validate projects-born URLs before they render site-wide data/projects-born.json was the only hand-maintained data file feeding an with no validator behind it. src/components/ProjectsBorn renders each entry's url verbatim, and that component is mounted in src/theme/Footer, so the links ship on every page of the site. Add scripts/validate-projects-born.mjs, which parses every url and rejects non-https, userinfo-bearing and unparseable values, and requires name, origin and description to be non-empty and names to be unique. Parsing rather than prefix-testing matches checkHttpsUrl() in validate-awards.mjs and checkUrl() in validate-metrics.mjs: /^https:\/\// accepts "https://www.cncf.io@evil.example/", whose visible prefix and real host disagree. No host allow-list, since these are legitimately third-party project sites. Registering the script in READ_ONLY_VALIDATORS is what puts it on the pull request gate; tests/validator-smoke-coverage.test.mjs already enforces that every scripts/validate-*.mjs appears there. Signed-off-by: hivecommons-hive[bot] --- package.json | 1 + scripts/validate-projects-born.mjs | 105 ++++++++++++++++ tests/validate-projects-born.test.mjs | 166 ++++++++++++++++++++++++++ tests/validators-smoke.test.mjs | 1 + 4 files changed, 273 insertions(+) create mode 100644 scripts/validate-projects-born.mjs create mode 100644 tests/validate-projects-born.test.mjs diff --git a/package.json b/package.json index 8a8d3008..b2029dbb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "validate:radar-reports": "node scripts/validate-radar-reports.mjs", "validate:community-people": "node scripts/validate-community-people.mjs", "validate:community-groups": "node scripts/validate-community-groups.mjs", + "validate:projects-born": "node scripts/validate-projects-born.mjs", "pr-queue-hygiene": "node scripts/pr-queue-hygiene.mjs", "test:unit": "TZ=UTC node --test", "test:unit:coverage": "TZ=UTC node tests/tools/coverage-report.mjs", diff --git a/scripts/validate-projects-born.mjs b/scripts/validate-projects-born.mjs new file mode 100644 index 00000000..8b9bab5a --- /dev/null +++ b/scripts/validate-projects-born.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { reportAndExit } from './lib/validate-utils.mjs'; + +// data/projects-born.json is hand-maintained, and src/components/ProjectsBorn +// renders every entry's `url` as an . That component is mounted in +// src/theme/Footer, so these links ship on every page of the site rather than +// on one section of the homepage. +// +// It was the only data file feeding an with no validator behind it, +// which left a maintainer skimming a JSON diff as the sole gate. A scheme +// prefix test would not be one either: /^https:\/\// accepts +// "https://www.cncf.io@evil.example/", whose visible prefix and real host +// disagree, and accepts unparseable values such as "https://". Parse the URL +// instead, the same standard checkHttpsUrl() in validate-awards.mjs and +// checkUrl() in validate-metrics.mjs already apply. +// +// No host allow-list: these are legitimately third-party project sites +// (envoyproxy.io, jaegertracing.io, backstage.io), unlike the cncf.io feeds +// guarded by validate-radar-reports.mjs and validate-case-studies.mjs. + +const REQUIRED_TEXT_FIELDS = ['name', 'origin', 'description']; + +function checkUrl(errors, path, value) { + if (typeof value !== 'string' || !value.trim()) { + errors.push({ + path, + severity: 'error', + message: 'url must be a non-empty string', + }); + return; + } + + let parsed; + try { + parsed = new URL(value.trim()); + } catch { + errors.push({ + path, + severity: 'error', + message: `url must be an absolute https URL: ${JSON.stringify(value)}`, + }); + return; + } + + if (parsed.protocol !== 'https:') { + errors.push({ + path, + severity: 'error', + message: `url must use https, got ${parsed.protocol}`, + }); + return; + } + + if (parsed.username || parsed.password) { + errors.push({ + path, + severity: 'error', + message: `url must not carry a userinfo component, which only disguises the real host (${parsed.hostname})`, + }); + } +} + +const data = JSON.parse( + readFileSync(new URL('../data/projects-born.json', import.meta.url)), +); +const errors = []; + +if (!Array.isArray(data) || !data.length) { + errors.push({ + path: 'projects-born.json', + severity: 'error', + message: 'projects-born.json must be a non-empty array', + }); +} + +const names = new Set(); +for (const entry of Array.isArray(data) ? data : []) { + const path = typeof entry?.name === 'string' ? entry.name : 'unknown'; + + for (const field of REQUIRED_TEXT_FIELDS) { + const value = entry?.[field]; + if (typeof value !== 'string' || !value.trim()) { + errors.push({ + path, + severity: 'error', + message: `${field} must be a non-empty string`, + }); + } + } + + if (typeof entry?.name === 'string' && names.has(entry.name)) { + errors.push({ + path, + severity: 'error', + message: 'duplicate name', + }); + } + names.add(entry?.name); + + checkUrl(errors, path, entry?.url); +} + +reportAndExit(errors, 'projects born'); +console.log(`Validated ${data.length} born projects`); diff --git a/tests/validate-projects-born.test.mjs b/tests/validate-projects-born.test.mjs new file mode 100644 index 00000000..a839f403 --- /dev/null +++ b/tests/validate-projects-born.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runScriptWithFixtures } from './helpers.mjs'; + +const SCRIPT = 'validate-projects-born.mjs'; + +const validEntry = { + name: 'Envoy', + origin: 'Lyft', + description: 'Originally built at Lyft before becoming a CNCF project.', + url: 'https://www.envoyproxy.io/', +}; + +const validData = [ + validEntry, + { + name: 'Jaeger', + origin: 'Uber', + description: 'Open sourced by Uber to make distributed tracing practical.', + url: 'https://www.jaegertracing.io/', + }, +]; + +function fixture(data) { + return { 'data/projects-born.json': JSON.stringify(data) }; +} + +test('accepts a valid projects-born file', () => { + const result = runScriptWithFixtures(SCRIPT, fixture(validData)); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Validated 2 born projects/); +}); + +test('rejects a file that is not an array', () => { + const result = runScriptWithFixtures(SCRIPT, fixture({ projects: [] })); + assert.equal(result.status, 1); + assert.match(result.stderr, /must be a non-empty array/); +}); + +test('rejects an empty array', () => { + const result = runScriptWithFixtures(SCRIPT, fixture([])); + assert.equal(result.status, 1); + assert.match(result.stderr, /must be a non-empty array/); +}); + +// The control this validator exists for: the visible prefix reads as the +// project's own site while the request resolves to the userinfo-suffixed host. +test('rejects a url carrying a userinfo component', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([ + { ...validEntry, url: 'https://www.envoyproxy.io@evil.example/' }, + ]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /must not carry a userinfo component/); + assert.match(result.stderr, /evil\.example/); +}); + +test('rejects a non-https url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, url: 'http://www.envoyproxy.io/' }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /must use https, got http:/); +}); + +test('rejects a javascript: url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, url: 'javascript:alert(1)' }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /must use https, got javascript:/); +}); + +test('rejects an unparseable url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, url: 'www.envoyproxy.io' }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /must be an absolute https URL/); +}); + +test('rejects a missing url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, url: undefined }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be a non-empty string/); +}); + +test('rejects a blank url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, url: ' ' }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be a non-empty string/); +}); + +test('rejects a non-string url', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, url: 42 }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /url must be a non-empty string/); +}); + +for (const field of ['name', 'origin', 'description']) { + test(`rejects a missing ${field}`, () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, [field]: undefined }]), + ); + assert.equal(result.status, 1); + assert.match( + result.stderr, + new RegExp(`${field} must be a non-empty string`), + ); + }); + + test(`rejects a blank ${field}`, () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, [field]: ' ' }]), + ); + assert.equal(result.status, 1); + assert.match( + result.stderr, + new RegExp(`${field} must be a non-empty string`), + ); + }); +} + +// A non-string name still has to produce a readable error path rather than +// crashing the report, so the entry is labelled "unknown". +test('labels an entry with a non-string name as unknown', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([{ ...validEntry, name: 7 }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /unknown: name must be a non-empty string/); +}); + +test('rejects duplicate names', () => { + const result = runScriptWithFixtures( + SCRIPT, + fixture([validEntry, { ...validEntry }]), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /duplicate name/); +}); + +// A null entry must be reported rather than crash the walk on property access. +test('reports a null entry instead of throwing', () => { + const result = runScriptWithFixtures(SCRIPT, fixture([null])); + assert.equal(result.status, 1); + assert.match(result.stderr, /unknown: name must be a non-empty string/); + assert.match(result.stderr, /unknown: url must be a non-empty string/); +}); diff --git a/tests/validators-smoke.test.mjs b/tests/validators-smoke.test.mjs index fe9c2752..b64a04b0 100644 --- a/tests/validators-smoke.test.mjs +++ b/tests/validators-smoke.test.mjs @@ -21,6 +21,7 @@ const READ_ONLY_VALIDATORS = [ 'validate-radar-reports.mjs', 'validate-community-people.mjs', 'validate-community-groups.mjs', + 'validate-projects-born.mjs', ]; for (const script of READ_ONLY_VALIDATORS) {