Security Finding
Severity: medium
Type: unsafe-pattern (missing input validation on generated, third-party-derived data)
File: scripts/validate-architectures.mjs (sole file; no open PR touches it)
scripts/validate-architectures.mjs is the only gate between
data/architectures/catalog.json — which scripts/import-architectures.mjs
regenerates every night from the third-party cncf/architecture repository —
and the published site. It checks id, title, organization presence,
duplicate ids, and asset existence. It never checks two fields that are
rendered directly into the site.
1. record.sourceUrl is not validated at all — not presence, not scheme
sourceUrl is copied by scripts/generate-members.mjs into data/members.json:
scripts/generate-members.mjs:130 → architectures[].sourceUrl
scripts/generate-members.mjs:145-146 → sourceAttribution
and sourceAttribution is rendered as a link with no scheme guard:
src/components/MemberDirectory/index.js:196
<a href={url} target="_blank" rel="noopener noreferrer">
A javascript: value therefore reaches an href sink through a validator that
reports success. This is the same defect class already fixed for
data/metrics.json (#303) and data/awards.json (#327) — both of those
validators grew an explicit scheme check; the architecture catalog never did.
2. record.assets[] containment is never checked, and the existence probe escapes static/
scripts/validate-architectures.mjs:17
for (const asset of record.assets ?? [])
if (!existsSync(join(root, 'static', asset.replace(/^\//, ''))))
asset.replace(/^\//, '') strips only the leading slash; join() then
normalises any .. segments away, so the probe leaves the repository entirely.
There is no assertion that an asset lives under /img/architectures/.
This is exactly the containment bug #327 records for validate-awards.mjs
(raw value tested, normalised value used), reproduced in a second validator.
Reproduction
Against a clean checkout at 00b44df:
$ node -e "
const fs=require('fs');const p='data/architectures/catalog.json';
const c=JSON.parse(fs.readFileSync(p));
c[0].sourceUrl='javascript:alert(document.domain)';
c[0].assets.push('/../../../../etc/hostname');
fs.writeFileSync(p,JSON.stringify(c,null,2));"
$ node scripts/validate-architectures.mjs
Validated 7 architecture records
$ echo $?
0
And the escape itself:
$ node -e "const{join}=require('node:path');
console.log(join('/tmp/endusers/','static','/../../../../etc/hostname'.replace(/^\//,'')));"
/etc/hostname
Impact
- A
javascript: (or data:) sourceUrl introduced into the catalog reaches
an href in MemberDirectory on the published site, having passed
npm run validate:architectures in deploy-gh-pages.yml. Stored XSS in the
site origin.
- The asset gate is not a containment gate: an asset path may point anywhere
under the site origin (or, for the build-time probe, anywhere on the build
runner's filesystem), and the validator still reports success.
- Because the catalog is regenerated nightly from an upstream repository this
project does not control, "the data is repo-controlled" is not a mitigation
here — it is precisely the assumption this validator exists to stop relying on.
Recommendation
Harden scripts/validate-architectures.mjs so the rendered fields are gated the
way validate-awards.mjs and validate-metrics.mjs gate theirs:
- Require
record.sourceUrl to be present and to parse as an https: URL via
new URL() — not a substring or prefix test.
- Require every
record.assets[] entry to be a site-absolute path under
/img/architectures/, and perform the containment check on the resolved
path (resolve first, then assert the prefix), so .. cannot normalise the
guard away. Only then call existsSync.
- Require
record.id to be a safe slug (/^[a-z0-9][a-z0-9-]*$/), since id
is used both as a route (/architectures/<id>) and as a filesystem path
component in import-architectures.mjs.
Scope
Single deliverable in a single file: scripts/validate-architectures.mjs.
Deliberately disjoint from the open work on import-architectures.mjs
(#195, #212, #216, #249, #266), validate-metrics.mjs (#304, #263),
validate-awards.mjs (#329) and validate-architecture-assets.mjs (#231).
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
Security Finding
Severity: medium
Type: unsafe-pattern (missing input validation on generated, third-party-derived data)
File:
scripts/validate-architectures.mjs(sole file; no open PR touches it)scripts/validate-architectures.mjsis the only gate betweendata/architectures/catalog.json— whichscripts/import-architectures.mjsregenerates every night from the third-party
cncf/architecturerepository —and the published site. It checks
id,title,organizationpresence,duplicate ids, and asset existence. It never checks two fields that are
rendered directly into the site.
1.
record.sourceUrlis not validated at all — not presence, not schemesourceUrlis copied byscripts/generate-members.mjsintodata/members.json:scripts/generate-members.mjs:130→architectures[].sourceUrlscripts/generate-members.mjs:145-146→sourceAttributionand
sourceAttributionis rendered as a link with no scheme guard:src/components/MemberDirectory/index.js:196A
javascript:value therefore reaches anhrefsink through a validator thatreports success. This is the same defect class already fixed for
data/metrics.json(#303) anddata/awards.json(#327) — both of thosevalidators grew an explicit scheme check; the architecture catalog never did.
2.
record.assets[]containment is never checked, and the existence probe escapesstatic/scripts/validate-architectures.mjs:17asset.replace(/^\//, '')strips only the leading slash;join()thennormalises any
..segments away, so the probe leaves the repository entirely.There is no assertion that an asset lives under
/img/architectures/.This is exactly the containment bug #327 records for
validate-awards.mjs(raw value tested, normalised value used), reproduced in a second validator.
Reproduction
Against a clean checkout at
00b44df:And the escape itself:
Impact
javascript:(ordata:)sourceUrlintroduced into the catalog reachesan
hrefinMemberDirectoryon the published site, having passednpm run validate:architecturesindeploy-gh-pages.yml. Stored XSS in thesite origin.
under the site origin (or, for the build-time probe, anywhere on the build
runner's filesystem), and the validator still reports success.
project does not control, "the data is repo-controlled" is not a mitigation
here — it is precisely the assumption this validator exists to stop relying on.
Recommendation
Harden
scripts/validate-architectures.mjsso the rendered fields are gated theway
validate-awards.mjsandvalidate-metrics.mjsgate theirs:record.sourceUrlto be present and to parse as anhttps:URL vianew URL()— not a substring or prefix test.record.assets[]entry to be a site-absolute path under/img/architectures/, and perform the containment check on the resolvedpath (resolve first, then assert the prefix), so
..cannot normalise theguard away. Only then call
existsSync.record.idto be a safe slug (/^[a-z0-9][a-z0-9-]*$/), sinceidis used both as a route (
/architectures/<id>) and as a filesystem pathcomponent in
import-architectures.mjs.Scope
Single deliverable in a single file:
scripts/validate-architectures.mjs.Deliberately disjoint from the open work on
import-architectures.mjs(#195, #212, #216, #249, #266),
validate-metrics.mjs(#304, #263),validate-awards.mjs(#329) andvalidate-architecture-assets.mjs(#231).Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)