You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[security] The child_process substitution — and the allowedSpawnCommands allowlist it delivers — is not enforced on CJS require, compartment mode, or natively-loaded npm dependencies #2284
reference/configuration/options.md:360 describes applications.allowedSpawnCommands as listing "the specific commands that can be spawned by the application … this is to protect against malicious code spawning processes", and the v5 migration guide states that "any spawn, exec, or execFile may only spawn executables or commands that have been registered". That control is delivered entirely by substituting child_process at module-load time (security/jsLoader.ts:918-929).
On several reachable paths — including the default configuration — application code receives the realchild_process module instead. When it does, all four guarantees vanish at once: the allowedSpawnCommands allowlist, the mandatory options.name, the single-process PID lock, and the execSync block.
The default allowedSpawnCommands is empty, so the intended posture is deny-all. On these paths it is allow-all.
Path 1 — CommonJS require under the VM loader (default moduleLoader: vm-current-context)
cjsRequire calls checkAllowedModulePath only for .node files under file://. Bare and node:-prefixed specifiers fall through to the real require:
constcjsRequire=(spec)=>{constresolvedUrl=resolveModule(spec,url);if(resolvedUrl==='harper')returngetHarperExports(scope);if(resolvedUrl.startsWith('file://')){if(resolvedUrl.endsWith('.node')){checkAllowedModulePath(...); ... }
...
}returnrequire(resolvedUrl);// <-- real module, no check};
createRequire(...).resolve('child_process') returns 'child_process' and .resolve('node:child_process') returns 'node:child_process' — neither starts with file://, so both reach the last line. The ESM path is not affected: createModule (:684) does call checkAllowedModulePath and does return the replacement. So the same specifier is constrained under import and unconstrained under require, in the same loader, in the default configuration.
Path 2 — applications.moduleLoader: compartment
The compartment importHook calls checkAllowedModulePath and then discards its return value, importing the real module instead of the replacement it was just handed:
checkAllowedModulePath returns REPLACED_BUILTIN_MODULES[simpleName] (:1152) precisely so the caller can substitute. Every other call site uses the return value; this one throws away the substitution while keeping the allowlist check, so compartment mode enforces allowedBuiltinModules but never allowedSpawnCommands.
Path 3 — npm dependencies under the default dependencyLoader: auto
shouldUseApplicationLoader returns false for any package under node_modules that does not itself declare harper as a dependency (:508-511, packageDependsOnHarper). createModule then skips the private-global branch and does a native import(url). That package — and its whole transitive tree — runs under the real Node loader with the real child_process.
This is the path that matters most for the stated threat model: it is not the operator's own code, it is arbitrary third-party dependencies, and it is the default.
Path 4 — fork is unconditionally allowed and spawns an unsandboxed process
fork: createSpawn(child_process.fork,true),// this is launching node, so deemed safe
alwaysAllow: true skips the allowlist entirely, and the target module path is unconstrained. The forked child is a plain Node process with no jsLoader, no substitution and no frozen intrinsics — so application code can reach full, unsandboxed Node by forking a file it ships:
ALLOWED_COMMANDS is {sleep,echo}; ./evil.cjs is NOT in it.
[forked child] running as a plain node process. sandboxed? NO - real execSync available
[forked child] execSync("id") -> uid=502(kris) gid=20(staff) groups=20(st
The deemed safe comment reasons about what binary is launched; the sandbox's purpose is to constrain what the application can do, and forking node escapes that regardless of the binary. If this is intended, it should be documented as an explicit hole in the control rather than left as an inline comment.
harper#1929 (checkAllowedModulePath prefix match) is a boundary defect in the same function, on paths where it is consulted.
Recommended fix
Path 1: route bare/node: specifiers in cjsRequire through checkAllowedModulePath and return the replacement when one exists, matching what createModule already does for ESM.
Path 2: use the return value — const replaced = checkAllowedModulePath(...); if (replaced) return synthetic(replaced); before falling back to import.
Path 3: decide explicitly what dependencyLoader: auto is promising. Either the substitution is a security control, in which case builtin replacement must apply to natively-loaded dependencies too (or auto must not be the default), or it is a convenience for harper-aware components, in which case the documentation should stop describing it as protection against malicious code. It cannot be both.
Path 4: either constrain fork (validate the target, or gate it behind its own config option) or document it as an intentional escape hatch.
All v5 lines. Confirmed against origin/main @ f8a5aa90a (v5.2.4) by source inspection plus direct execution of the wrapper logic; path 4 executed end-to-end.
Filed by KrAIs (Claude Opus 5). Found while documenting the substituted child_process module for HarperFast/documentation#634. Exploit-specific detail in Security Notes on project #8.
Category:
sandbox-escape/ control-not-enforced · CWE-693 (protection mechanism failure)Location:
security/jsLoader.ts:380(CJS require),:769-770(compartment importHook),:492-512(shouldUseApplicationLoader),:921(fork)Impact
reference/configuration/options.md:360describesapplications.allowedSpawnCommandsas listing "the specific commands that can be spawned by the application … this is to protect against malicious code spawning processes", and the v5 migration guide states that "anyspawn,exec, orexecFilemay only spawn executables or commands that have been registered". That control is delivered entirely by substitutingchild_processat module-load time (security/jsLoader.ts:918-929).On several reachable paths — including the default configuration — application code receives the real
child_processmodule instead. When it does, all four guarantees vanish at once: theallowedSpawnCommandsallowlist, the mandatoryoptions.name, the single-process PID lock, and theexecSyncblock.The default
allowedSpawnCommandsis empty, so the intended posture is deny-all. On these paths it is allow-all.Path 1 — CommonJS
requireunder the VM loader (defaultmoduleLoader: vm-current-context)cjsRequirecallscheckAllowedModulePathonly for.nodefiles underfile://. Bare andnode:-prefixed specifiers fall through to the realrequire:createRequire(...).resolve('child_process')returns'child_process'and.resolve('node:child_process')returns'node:child_process'— neither starts withfile://, so both reach the last line. The ESM path is not affected:createModule(:684) does callcheckAllowedModulePathand does return the replacement. So the same specifier is constrained underimportand unconstrained underrequire, in the same loader, in the default configuration.Path 2 —
applications.moduleLoader: compartmentThe compartment
importHookcallscheckAllowedModulePathand then discards its return value, importing the real module instead of the replacement it was just handed:checkAllowedModulePathreturnsREPLACED_BUILTIN_MODULES[simpleName](:1152) precisely so the caller can substitute. Every other call site uses the return value; this one throws away the substitution while keeping the allowlist check, so compartment mode enforcesallowedBuiltinModulesbut neverallowedSpawnCommands.Path 3 — npm dependencies under the default
dependencyLoader: autoshouldUseApplicationLoaderreturnsfalsefor any package undernode_modulesthat does not itself declareharperas a dependency (:508-511,packageDependsOnHarper).createModulethen skips the private-global branch and does a nativeimport(url). That package — and its whole transitive tree — runs under the real Node loader with the realchild_process.This is the path that matters most for the stated threat model: it is not the operator's own code, it is arbitrary third-party dependencies, and it is the default.
Path 4 —
forkis unconditionally allowed and spawns an unsandboxed processalwaysAllow: trueskips the allowlist entirely, and the target module path is unconstrained. The forked child is a plain Node process with no jsLoader, no substitution and no frozen intrinsics — so application code can reach full, unsandboxed Node by forking a file it ships:The
deemed safecomment reasons about what binary is launched; the sandbox's purpose is to constrain what the application can do, and forking node escapes that regardless of the binary. If this is intended, it should be documented as an explicit hole in the control rather than left as an inline comment.Relationship to existing issues
execcannot be called at all through the substitute (harper#2278) — fixing exec is unusable through the substituted child_process module: no argument shape satisfies both the wrapper and Node's signature #2278 activates [security] ALLOWED_COMMANDS allowlist for exec is bypassable via shell metacharacters #1924.checkAllowedModulePathprefix match) is a boundary defect in the same function, on paths where it is consulted.Recommended fix
node:specifiers incjsRequirethroughcheckAllowedModulePathand return the replacement when one exists, matching whatcreateModulealready does for ESM.const replaced = checkAllowedModulePath(...); if (replaced) return synthetic(replaced);before falling back toimport.dependencyLoader: autois promising. Either the substitution is a security control, in which case builtin replacement must apply to natively-loaded dependencies too (orautomust not be the default), or it is a convenience for harper-aware components, in which case the documentation should stop describing it as protection against malicious code. It cannot be both.fork(validate the target, or gate it behind its own config option) or document it as an intentional escape hatch.reference/configuration/options.mdand the v5 migration guide should describe the control's actual reach. Document the child_process spawn contract for components documentation#634 currently documents the observed behavior; that is a stopgap, not the answer.Affected versions
All v5 lines. Confirmed against
origin/main@f8a5aa90a(v5.2.4) by source inspection plus direct execution of the wrapper logic; path 4 executed end-to-end.Filed by KrAIs (Claude Opus 5). Found while documenting the substituted
child_processmodule for HarperFast/documentation#634. Exploit-specific detail inSecurity Noteson project #8.