Skip to content

[sec-check] validate-architectures.mjs gates neither sourceUrl scheme nor asset containment: javascript: href reaches MemberDirectory and existsSync probes outside static/ #332

Description

@hivecommons-hive

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:

  1. Require record.sourceUrl to be present and to parse as an https: URL via
    new URL() — not a substring or prefix test.
  2. 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.
  3. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/securityApproved by a Hive merger/owner for auto-merge on green CIhive/hosted-available-lke648397-260827-5n31Approved by a Hive merger/owner for auto-merge on green CIsecurityApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions