From 12c3e4d89d1e8dcfb7e9e566a6b4a221aa1b8fa9 Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Sat, 19 Sep 2026 20:36:40 -0400 Subject: [PATCH] [sec-check] fix: validate architecture catalog sourceUrl scheme and asset containment scripts/validate-architectures.mjs is the only gate between data/architectures/catalog.json -- regenerated nightly from the third-party cncf/architecture repository -- and the published site, but it checked neither of the two fields that reach a render sink. - sourceUrl was not validated at all. generate-members.mjs copies it into members.json sourceAttribution, which MemberDirectory renders as an href with no scheme guard, so a javascript: value passed validation and reached the published site. - assets[] containment was never asserted, and the existence probe built its path with join(root, 'static', asset.replace(/^\//, '')), which normalises '..' away -- so the probe escaped static/ entirely. sourceUrl must now parse as an https: URL via new URL(). Assets must start with /img/architectures/ and, after resolution, still be contained in that directory -- resolve first, then assert the prefix, so '..' cannot normalise the guard away. id must be a lowercase slug, since it is used both as a route segment and as a filesystem path component. Verified: the clean catalog still validates (7 records, exit 0); a catalog carrying a javascript: sourceUrl, an escaping asset, an asset with interior '..', and a '../evil' id is rejected with four errors and exit 1. Signed-off-by: kubestellar-hive[bot] --- scripts/validate-architectures.mjs | 38 ++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/scripts/validate-architectures.mjs b/scripts/validate-architectures.mjs index cc4965de..f7eb9be4 100644 --- a/scripts/validate-architectures.mjs +++ b/scripts/validate-architectures.mjs @@ -1,20 +1,54 @@ #!/usr/bin/env node import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve, sep } from 'node:path'; import { reportAndExit } from './lib/validate-utils.mjs'; const root = new URL('..', import.meta.url).pathname; +const staticRoot = resolve(join(root, 'static')); +const assetPrefix = '/img/architectures/'; +const assetRoot = resolve(join(staticRoot, assetPrefix.slice(1))); +const idPattern = /^[a-z0-9][a-z0-9-]*$/; + const catalogPath = join(root, 'data/architectures/catalog.json'); if (!existsSync(catalogPath)) throw new Error('Missing data/architectures/catalog.json; run npm run import:architectures'); +// The catalog is regenerated from the third-party cncf/architecture repository, +// so every field that reaches an href or an is treated as untrusted. +function isHttpsUrl(value) { + if (typeof value !== 'string') return false; + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +// Resolve first, then assert containment: '..' segments are normalised away by +// join()/resolve(), so testing the raw value would let the guard be bypassed. +function resolveContainedAsset(asset) { + if (typeof asset !== 'string' || !asset.startsWith(assetPrefix)) return null; + const resolved = resolve(join(staticRoot, asset.slice(1))); + if (resolved !== assetRoot && !resolved.startsWith(assetRoot + sep)) return null; + return resolved; +} + const records = JSON.parse(readFileSync(catalogPath, 'utf8')); const ids = new Set(); const errors = []; for (const record of records) { if (!record.id || !record.title || !record.organization) errors.push({ path: record.id || '', severity: 'error', message: 'missing id, title, or organization' }); + if (record.id && !idPattern.test(record.id)) errors.push({ path: record.id, severity: 'error', message: 'id must be a lowercase slug matching /^[a-z0-9][a-z0-9-]*$/; it is used as a route segment and as a filesystem path component' }); if (ids.has(record.id)) errors.push({ path: record.id, severity: 'error', message: 'duplicate id' }); ids.add(record.id); - for (const asset of record.assets ?? []) if (!existsSync(join(root, 'static', asset.replace(/^\//, '')))) errors.push({ path: record.id, severity: 'error', message: `missing asset ${asset}` }); + if (!isHttpsUrl(record.sourceUrl)) errors.push({ path: record.id || '', severity: 'error', message: 'sourceUrl must be an https URL; it is rendered as an href in the member directory' }); + for (const asset of record.assets ?? []) { + const file = resolveContainedAsset(asset); + if (!file) { + errors.push({ path: record.id, severity: 'error', message: `asset ${asset} must be a site-absolute path contained in ${assetPrefix}` }); + continue; + } + if (!existsSync(file)) errors.push({ path: record.id, severity: 'error', message: `missing asset ${asset}` }); + } } reportAndExit(errors, 'architecture catalog'); console.log(`Validated ${records.length} architecture records`);