HarperFast / HarperFast/harper
[security] Component require() of a plain .js file bypasses module-path confinement (only the .node branch is checked)
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
## Summary
A deployed component's CommonJS `require()` of a plain `.js` file is **not** subjected to the module-path confinement check that `import()` and native (`.node`) loads receive. A component can therefore `require()` an arbitrary local JavaScript file from outside its own `allowedPath` and execute it. This is a module-confinement gap adjacent to #2284 but a **distinct vector**: no builtin-name substitution and no crafted sibling-directory name is involved — an ordinary relative or absolute path to any `.js` on disk is enough.
## Mechanism (on `origin/main`)
`security/jsLoader.ts`, the `cjsRequire` closure inside `loadCJS`:
- The `.node` (native addon) branch calls `checkAllowedModulePath(resolvedUrl, scope.allowedPath)` before loading.
- The plain-JS branch immediately below reads and evaluates the resolved module (`readFileSync(new URL(resolvedUrl))`) **without** calling `checkAllowedModulePath` for any `file://`-resolved specifier that is not `.node`.
So confinement is enforced for native modules and for the `import()` path, but not for `require()` of a JS file. `checkAllowedModulePath` has several call sites; none guards this branch.
## Scope / threat model
Impactful wherever components are **not** fully trusted (multi-tenant, or any deployment that runs third-party/untrusted component code): it is arbitrary local-file read-and-execute from within a component. Inert where every deployed component is trusted, since deploying a component is already a privileged action. Filed at that threat-model boundary; see the private Security Notes for the exploit specifics and the discriminating PoC.
## Suggested fix
Call `checkAllowedModulePath(resolvedUrl, scope.allowedPath)` on the plain-JS branch of `cjsRequire`, matching the `.node` branch and the `import()` path — confinement should not depend on which loader form or file extension is used.
## Existing work checked
harper#2284 (OPEN) — same function, but its narrative and fix are scoped to bare/`node:` specifiers falling through to the real `require` (builtin-substitution / `child_process` allowlist bypass). It quotes this code block but does not state or fix the plain-`.js` `file://` no-confinement case. harper#1929 (OPEN) — a prefix-match boundary defect where the check *does* run. This finding is neither; it is the branch where the check never runs. Could be filed as a new Path on #2284 to keep the `cjsRequire` story in one place, or standalone (this issue).
## What this does not prove
Whether other loader entry points share the gap; the precise interaction with compartment mode (see #2284's compartment path).
---
*From dispatch QA finding F-209, verified against harper origin/main, 2026-08-30. Exploit specifics in the private project Security Notes field, per Harper's public-issue security convention.*
Contributor guide
Research direction
Start in security/jsLoader.ts at the cjsRequire closure inside loadCJS, then read checkAllowedModulePath and compare the plain-JS, .node, and import() branches. Confirm the relevant loader behavior against the issue's described relative or absolute .js case. Done means plain-JS file:// loads receive the same confinement treatment without changing the existing native-module and import() paths.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 75/100