Skip to content

ESM: internal dynamic import('node:module') self-blocked by security guard on Node ≥22.15 → parameter expansion returns empty/exit 1 #288

Description

@yakimoto

Summary

Parameter expansion (${x//old/new}, ${x^^}, ${x:offset:len}, ${#x}) silently fails — returns empty stdout with exit code 1 — when just-bash is imported as ESM on Node ≥ 22.23. The CommonJS entry works correctly on the same Node version, and both entries work on Node ≤ 22.14.

Version: just-bash@3.0.2.

Repro

t.mjs (ESM) — fails on Node ≥22.23, passes on ≤22.14:

import { Bash } from 'just-bash';
const r = await new Bash().exec('x=foobar; echo ${x//o/0}');
console.log(JSON.stringify(r.stdout.trim()), r.exitCode);
// Node 22.23.1 / 24.x : ""      1
// Node 22.14.0 / 20.x : "f00bar" 0

t.cjs (CommonJS) — passes on all Node versions, including 22.23+:

const { Bash } = require('just-bash');
new Bash().exec('x=foobar; echo ${x//o/0}')
  .then(r => console.log(JSON.stringify(r.stdout.trim()), r.exitCode));
// all versions: "f00bar" 0

Matrix (verified in clean node:* Docker images)

Node CJS require ESM import
20.20.2 f00bar f00bar
22.14.0 f00bar f00bar
22.23.1 f00bar "" exit 1
24.18.0 f00bar "" exit 1

Verified across ${x//old/new}, ${x^^}, ${x:offset:len}, and ${#x} — all four break together on ESM ≥22.23 and all four pass via CJS on every version.

Scope

  • Affects the family of expansions that route through the same path: ${x//}, ${x^^}, ${x,,}, ${x:o:l}, ${#x}.
  • Not affected: basic commands, pipes, loops, conditionals, jq, arithmetic $(( )), error/exit-code parity, containment/execution-limits — all behave identically ESM vs CJS.
  • The flip is purely ESM entry × Node ≥22.23; isolating either variable alone doesn't reproduce it.

Impact

ESM consumers on current Node (22.23+/24) get silently wrong results for common string operations. Workaround today is pinning Node ≤22.14 or loading the CJS build via createRequire.

Mechanism (captured from result.stderr)

The failing call returns this on the stderr channel (stdout empty, exit 1):

bash: security violation: dynamic import of Node.js builtin 'node:module' is blocked during script execution

This is a defense-in-depth measure and indicates a bug in just-bash. Please report this at security@vercel.com

So just-bash's own dynamic-import guard is blocking an internal import('node:module') (used to createRequire the esbuild shim) that the ESM build reaches only on Node ≥22.15. CJS never takes that path (native require), which is why CJS is unaffected on every version. It is also order-sensitive: the first expansion that triggers the lazy node:module import is blocked, while some later calls in the same process succeed — so a batch shows partial failures rather than total. Likely fix: statically import / allowlist just-bash's own node:module use so the sandbox guard doesn't block it. (Reporting here as a functional bug rather than to security@vercel.com since it's a self-block, not an escape — happy to move it if you prefer.)

Possibly related

Closed #211 ("Dynamic require of tty is not supported when used as ESM in Node") points at dynamic-require fragility in the ESM bundle. A silently-failing dynamic require on the expansion code path (surfacing only after a Node ≥22.23 module-resolution change) would explain why this reproduces only via the ESM entry and only on newer Node, while CJS is unaffected — but I haven't confirmed the exact mechanism.

Happy to provide the full 26-case conformance battery or a bisected build diff if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions