HarperFast / HarperFast/harper

[security] Component require() of a plain .js file bypasses module-path confinement (only the .node branch is checked)

Open
#2,396 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.