From 88441a914af2028c6f46d94142af9fc2a7255a7a Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Thu, 17 Sep 2026 11:03:48 -0400 Subject: [PATCH] [sec-check] fix: validate imported project card href by URL, not substring renderProjectCards() chose each published link target with link.includes('cncf.io/projects/'), which any host satisfies by putting that text in a path, query or fragment. Card content comes from cncf/architecture and is imported unattended by the daily import workflow, so upstream Markdown could point a CNCF-branded card at an arbitrary origin. Add scripts/lib/project-card-links.mjs with isCncfProjectHref(), which parses the URL and requires https, no userinfo, a cncf.io (or subdomain) host and a /projects/ path, and use it to select the href. All 50 cards currently in docs/architectures/ already satisfy the predicate, so generated output is unchanged. Signed-off-by: kubestellar-hive[bot] --- scripts/import-architectures.mjs | 3 +- scripts/lib/project-card-links.mjs | 34 ++++++++++++++ tests/project-card-links.test.mjs | 73 ++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/project-card-links.mjs create mode 100644 tests/project-card-links.test.mjs diff --git a/scripts/import-architectures.mjs b/scripts/import-architectures.mjs index 12c02a63..be890c4b 100644 --- a/scripts/import-architectures.mjs +++ b/scripts/import-architectures.mjs @@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process'; import { join, relative } from 'node:path'; import { tmpdir } from 'node:os'; import { parse as yamlParse } from 'yaml'; +import { isCncfProjectHref } from './lib/project-card-links.mjs'; const root = new URL('..', import.meta.url).pathname; const upstream = mkdtempSync(join(tmpdir(), 'cncf-architecture-')); @@ -135,7 +136,7 @@ function renderProjectCards(body, id) { (match) => match[1], ); const href = - links.find((link) => link.includes('cncf.io/projects/')) || + links.find(isCncfProjectHref) || `https://www.cncf.io/projects/${name.toLowerCase().replace(/\s+/g, '-')}/`; const logo = (content.match(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/) || [])[1]; diff --git a/scripts/lib/project-card-links.mjs b/scripts/lib/project-card-links.mjs new file mode 100644 index 00000000..ba09d374 --- /dev/null +++ b/scripts/lib/project-card-links.mjs @@ -0,0 +1,34 @@ +// Link validation for imported CNCF project cards. +// +// Card link targets come from Markdown in https://github.com/cncf/architecture, +// which is cloned and imported unattended by the daily import workflow. A +// substring test such as `link.includes('cncf.io/projects/')` is satisfied by +// any host that merely mentions that text in a path, query or fragment, so the +// destination must be decided by parsing the URL rather than by matching text. + +/** + * True only for an https URL whose host is cncf.io (or a subdomain of it) and + * whose path is under /projects/. + * + * @param {string} value + * @returns {boolean} + */ +export function isCncfProjectHref(value) { + if (typeof value !== 'string') return false; + + let url; + try { + url = new URL(value); + } catch { + return false; + } + + if (url.protocol !== 'https:') return false; + // Userinfo lets "https://www.cncf.io@evil.example/projects/x" read as CNCF. + if (url.username || url.password) return false; + + const host = url.hostname.toLowerCase(); + if (host !== 'cncf.io' && !host.endsWith('.cncf.io')) return false; + + return url.pathname === '/projects' || url.pathname.startsWith('/projects/'); +} diff --git a/tests/project-card-links.test.mjs b/tests/project-card-links.test.mjs new file mode 100644 index 00000000..80c4f788 --- /dev/null +++ b/tests/project-card-links.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { isCncfProjectHref } from '../scripts/lib/project-card-links.mjs'; + +test('accepts canonical CNCF project URLs', () => { + assert.equal( + isCncfProjectHref('https://www.cncf.io/projects/kubernetes/'), + true, + ); + assert.equal(isCncfProjectHref('https://cncf.io/projects/helm/'), true); + assert.equal(isCncfProjectHref('https://www.cncf.io/projects'), true); + assert.equal( + isCncfProjectHref('https://www.cncf.io/projects/argo/?ref=architecture'), + true, + ); +}); + +test('rejects a foreign host that merely mentions the CNCF project path', () => { + assert.equal( + isCncfProjectHref('https://evil.example/r?u=cncf.io/projects/kubernetes/'), + false, + ); + assert.equal( + isCncfProjectHref('https://evil.example/cncf.io/projects/kubernetes/'), + false, + ); + assert.equal( + isCncfProjectHref('https://evil.example/#cncf.io/projects/kubernetes/'), + false, + ); +}); + +test('rejects lookalike hosts', () => { + assert.equal( + isCncfProjectHref('https://cncf.io.evil.example/projects/x'), + false, + ); + assert.equal(isCncfProjectHref('https://notcncf.io/projects/x'), false); +}); + +test('rejects userinfo that disguises the real host', () => { + assert.equal( + isCncfProjectHref('https://www.cncf.io@evil.example/projects/x'), + false, + ); +}); + +test('rejects non-https schemes', () => { + assert.equal( + isCncfProjectHref('http://www.cncf.io/projects/kubernetes/'), + false, + ); + assert.equal( + isCncfProjectHref('javascript:alert(1)//cncf.io/projects/x'), + false, + ); +}); + +test('rejects CNCF URLs outside /projects/', () => { + assert.equal(isCncfProjectHref('https://www.cncf.io/blog/projects/x'), false); + assert.equal( + isCncfProjectHref('https://www.cncf.io/projectsomething'), + false, + ); +}); + +test('rejects non-URL and non-string input', () => { + assert.equal(isCncfProjectHref('not a url'), false); + assert.equal(isCncfProjectHref(''), false); + assert.equal(isCncfProjectHref(undefined), false); + assert.equal(isCncfProjectHref(null), false); +});