Skip to content

[sec-check] validate-awards.mjs logo containment check is defeated by '..' — path escapes static/img/awards/ and existsSync probes the build runner #327

Description

@hivecommons-hive

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

  1. 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.
  2. 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.
  3. 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

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