Security Finding
Severity: low
Type: unsafe-pattern (path-containment bypass)
File: scripts/validate-awards.mjs (logo containment check)
scripts/validate-awards.mjs enforces that every award logo lives under
static/img/awards/, but it does the check with a string prefix test on the
raw value and then resolves that same raw value through new URL(), which
normalises .. segments away:
if (!entry.logo.startsWith('/img/awards/'))
errors.push({ path: id, severity: 'error', message: 'logo must live under /img/awards/' });
const file = fileURLToPath(new URL(`../static${entry.logo}`, import.meta.url));
if (!existsSync(file))
errors.push({ path: id, severity: 'error', message: `logo file missing: ${entry.logo}` });
A value such as /img/awards/../../../../../etc/hostname satisfies
startsWith('/img/awards/'), so the containment error is never pushed, and
new URL() then resolves it completely outside the repository. Reproduced on
main @ 00b44df:
startsWith gate passes: true
resolves to: /etc/hostname
existsSync: true
The validator reports Validated N award entries and exits 0.
Impact
- The validator's own invariant is bypassed. The check exists precisely to
keep logo assets inside static/img/awards/; any .. value defeats it, so
npm run validate:awards green-lights an award entry whose logo is not a
site asset at all.
existsSync becomes a filesystem oracle on the build runner. Because
the existence probe runs on the un-contained path, a contributor-authored
data/awards.json edit can confirm the presence or absence of any absolute
path on the GitHub Actions runner by watching whether validate:awards
passes or emits logo file missing. npm run validate:awards runs in both
deploy-gh-pages.yml and import-architectures.yml.
- The escaped path ships to the published site.
scripts/generate-members.mjs
pickLogo() falls back to awardEntries[0]?.logo, copying the value into
data/members.json; src/components/AwardsTimeline/index.js:18,30 and
src/components/MemberDirectory/index.js:36 then render it as
<img src={useBaseUrl(logo)}>, so a traversal value is emitted verbatim as
an image URL resolved against the site root.
Not currently exploited — all 10 logo values in data/awards.json today are
plain /img/awards/*.svg. This is a defence-in-depth fix for a gate that does
not currently hold.
A secondary crash: entry.logo.startsWith(...) throws TypeError if logo is
a non-string (e.g. a number or object from a bad edit), aborting the validator
with a stack trace instead of a reported error.
Recommendation
Resolve the path first, then check containment against the resolved
static/img/awards/ directory, and only probe for existence once containment
holds. Also reject a non-string logo before calling string methods:
const staticRoot = fileURLToPath(new URL('../static/', import.meta.url));
const awardsRoot = join(staticRoot, 'img', 'awards') + sep;
...
if (entry.logo !== undefined && entry.logo !== null) {
if (typeof entry.logo !== 'string') {
errors.push({ path: id, severity: 'error', message: 'logo must be a string path under /img/awards/' });
} else {
const file = resolve(staticRoot, `.${entry.logo}`);
if (!entry.logo.startsWith('/img/awards/') || !file.startsWith(awardsRoot)) {
errors.push({ path: id, severity: 'error', message: 'logo must live under /img/awards/' });
} else if (!existsSync(file)) {
errors.push({ path: id, severity: 'error', message: `logo file missing: ${entry.logo}` });
}
}
}
Cover it in tests/validate-awards.test.mjs with a .. traversal case and a
non-string case alongside the existing rejects logos outside /img/awards/ test.
Scope
This issue is limited to scripts/validate-awards.mjs and
tests/validate-awards.test.mjs. It does not overlap the open URL-scheme work
on scripts/validate-metrics.mjs (#303 / PR #304), the architecture-asset
validator work (#194 / PR #195, PR #231), or the data/members.json contract
test (PR #253).
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
— hive: agent=sec-check backend=copilot model=claude-opus-5
Security Finding
Severity: low
Type: unsafe-pattern (path-containment bypass)
File:
scripts/validate-awards.mjs(logo containment check)scripts/validate-awards.mjsenforces that every award logo lives understatic/img/awards/, but it does the check with a string prefix test on theraw value and then resolves that same raw value through
new URL(), whichnormalises
..segments away:A value such as
/img/awards/../../../../../etc/hostnamesatisfiesstartsWith('/img/awards/'), so the containment error is never pushed, andnew URL()then resolves it completely outside the repository. Reproduced onmain@00b44df:The validator reports
Validated N award entriesand exits 0.Impact
keep logo assets inside
static/img/awards/; any..value defeats it, sonpm run validate:awardsgreen-lights an award entry whose logo is not asite asset at all.
existsSyncbecomes a filesystem oracle on the build runner. Becausethe existence probe runs on the un-contained path, a contributor-authored
data/awards.jsonedit can confirm the presence or absence of any absolutepath on the GitHub Actions runner by watching whether
validate:awardspasses or emits
logo file missing.npm run validate:awardsruns in bothdeploy-gh-pages.ymlandimport-architectures.yml.scripts/generate-members.mjspickLogo()falls back toawardEntries[0]?.logo, copying the value intodata/members.json;src/components/AwardsTimeline/index.js:18,30andsrc/components/MemberDirectory/index.js:36then render it as<img src={useBaseUrl(logo)}>, so a traversal value is emitted verbatim asan image URL resolved against the site root.
Not currently exploited — all 10 logo values in
data/awards.jsontoday areplain
/img/awards/*.svg. This is a defence-in-depth fix for a gate that doesnot currently hold.
A secondary crash:
entry.logo.startsWith(...)throwsTypeErroriflogoisa non-string (e.g. a number or object from a bad edit), aborting the validator
with a stack trace instead of a reported error.
Recommendation
Resolve the path first, then check containment against the resolved
static/img/awards/directory, and only probe for existence once containmentholds. Also reject a non-string
logobefore calling string methods:Cover it in
tests/validate-awards.test.mjswith a..traversal case and anon-string case alongside the existing
rejects logos outside /img/awards/test.Scope
This issue is limited to
scripts/validate-awards.mjsandtests/validate-awards.test.mjs. It does not overlap the open URL-scheme workon
scripts/validate-metrics.mjs(#303 / PR #304), the architecture-assetvalidator work (#194 / PR #195, PR #231), or the
data/members.jsoncontracttest (PR #253).
Filed by sec-check agent (ACMM L4/L5 — hold-gated mode)
— hive: agent=sec-check backend=copilot model=claude-opus-5