Skip to content

fix(plugins): inject globalThis.require for CJS interop in jiti-loaded extensions#13109

Closed
mcaxtr wants to merge 5 commits intoopenclaw:mainfrom
mcaxtr:fix/12854-cjs-require-extensions
Closed

fix(plugins): inject globalThis.require for CJS interop in jiti-loaded extensions#13109
mcaxtr wants to merge 5 commits intoopenclaw:mainfrom
mcaxtr:fix/12854-cjs-require-extensions

Conversation

@mcaxtr
Copy link
Copy Markdown
Member

@mcaxtr mcaxtr commented Feb 10, 2026

Summary

Fixes #12854

Extensions loaded via jiti run in an ESM context where require is unavailable. CJS dependencies that internally call require() (e.g. @vector-im/matrix-bot-sdk calling require("events")) crash at runtime with ReferenceError: require is not defined.

This PR:

  • Injects a globalThis.require shim at module load time via createRequire(import.meta.url) so CJS packages always have a usable require
  • Re-anchors globalThis.require to each plugin's source path before jiti() evaluates it, so CJS dependencies resolve from the plugin's own node_modules
  • Saves/restores the pre-existing globalThis.require around the plugin loading loop so runtime code outside the loader is unaffected

Test plan

  • New test: injects globalThis.require for CJS interop in jiti-loaded plugins — verifies the shim exists and resolves built-in modules
  • New test: loads plugins whose CJS dependencies call require() at runtime — creates a real CJS helper that calls require("node:path"), loads a plugin that depends on it, verifies status === "loaded"
  • All 16 loader tests pass (TDD: both new tests fail before the fix, pass after)
  • pnpm build && pnpm check clean

Greptile Overview

Greptile Summary

This change adjusts the plugin loader so extensions evaluated via jiti (ESM context) have a require available for CommonJS interop. It injects a globalThis.require shim via createRequire(import.meta.url), re-anchors globalThis.require to each plugin’s source path before jiti() evaluation so CJS dependencies resolve from the plugin’s own node_modules, and saves/restores any pre-existing globalThis.require around the plugin loading loop. Tests are added to verify the shim exists and can resolve built-in modules, and that plugins with CJS dependencies calling require() at runtime load successfully.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk.
  • The change is narrowly scoped to plugin loading, adds targeted tests that exercise the new behavior (globalThis.require injection and per-plugin require anchoring), and does not introduce broader behavioral changes beyond the loader’s evaluation context.
  • No files require special attention

@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch from bd8d05a to 55a12d2 Compare February 12, 2026 04:13
@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch from 55a12d2 to dc8a9e6 Compare February 13, 2026 02:26
@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch from dc8a9e6 to 0a3f783 Compare February 13, 2026 14:36
@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch 8 times, most recently from 0a7cbca to 1edfc49 Compare February 15, 2026 14:49
@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch 3 times, most recently from 04091d6 to 78046a8 Compare February 19, 2026 02:05
@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch 2 times, most recently from 0558af0 to deb65d0 Compare February 20, 2026 03:37
@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch 2 times, most recently from 13dce52 to fbc0d97 Compare March 3, 2026 03:50
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 3, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Uncontrolled module resolution root via createRequire(candidate.source) when plugin path contains symlinks
2 🔵 Low Global require shim can remain corrupted if plugin loading throws before restoration

1. 🟠 Uncontrolled module resolution root via createRequire(candidate.source) when plugin path contains symlinks

Property Value
Severity High
CWE CWE-427
Location src/plugins/loader.ts:712-714

Description

loadOpenClawPlugins() sets a global require for each plugin using createRequire(candidate.source). However, candidate.source originates from plugin discovery/config as a resolved but not necessarily canonical path (it may include symlink segments).

Because Node computes module.paths from the path passed to createRequire(), using a non-canonical/symlinked candidate.source can cause CommonJS resolution to search node_modules in ancestor directories of the symlink path (e.g., /tmp/alias/node_modules, /tmp/node_modules, etc.) instead of the plugin’s canonical root.

Security impact:

  • A local attacker who can influence the plugin entry path to include a symlinked prefix (or can control directories in that symlink’s ancestor chain) may be able to place a malicious package in an earlier searched node_modules directory.
  • When the plugin (or its dependencies) calls require("some-package"), Node may load the attacker-controlled module, resulting in arbitrary code execution in the OpenClaw process.
  • This can undermine the project’s existing “plugin root” boundary/ownership checks, because those checks validate the plugin entry file, not the node_modules search path induced by the symlinked candidate.source.

Vulnerable code:

// Anchor require() resolution to the plugin's directory so its CJS// dependencies resolve packages from the plugin's own node_modules.
globalThis.require = createRequire(candidate.source);

Recommendation

Anchor createRequire() to the canonical, boundary-validated path you already computed (safeSource / opened.path) instead of the raw discovered path.

Suggested fix:

// Use the canonical path returned by openBoundaryFileSync()
globalThis.require = createRequire(safeSource);

Optionally, also consider:

  • Using pathToFileURL(safeSource) if you want to be explicit about URL handling.
  • Avoiding a process-global require if possible (pass a scoped require into the plugin/jiti context) to reduce cross-plugin interference.

2. 🔵 Global require shim can remain corrupted if plugin loading throws before restoration

Property Value
Severity Low
CWE CWE-703
Location src/plugins/loader.ts:712-716

Description

loadOpenClawPlugins() mutates globalThis.require to a per-plugin createRequire() so jiti-loaded code can call require(). However, restoration back to the original savedRequire happens only at the end of the function and is not protected by try/finally.

This means any uncaught exception between setting savedRequire and the final restore (including attacker-controlled exceptions from plugin exports) can leave the process-wide globalThis.require permanently pointing at a plugin directory.

Impact:

  • Denial of service / reliability break: subsequent code (or later plugin loads) may resolve modules from the wrong location or crash unexpectedly.
  • Cross-plugin contamination: a plugin can intentionally trigger an exception after globalThis.require is switched, causing other code to run under altered module-resolution semantics.

Vulnerable pattern:

const savedRequire = globalThis.require;
...
globalThis.require = createRequire(candidate.source);
...
​// not in finally
globalThis.require = savedRequire;

Because resolvePluginModuleExport(mod), config/schema validation, or other logic after import can throw outside the existing try/catch blocks, a malicious plugin can intentionally prevent restoration and leave global state corrupted.

Recommendation

Use try/finally to guarantee restoration even if any plugin-related step throws. Ideally, restore per plugin as well to minimize the window of global mutation.

Example (outer guard + per-plugin guard):

const savedRequire = globalThis.require;
try {
  for (const candidate of discovery.candidates) {
    const pluginRequire = createRequire(candidate.source);
    const prior = globalThis.require;
    globalThis.require = pluginRequire;
    try {
      const mod = getJiti()(safeSource) as OpenClawPluginModule;// ... resolve exports, validate config, register, etc.
    } finally {
      globalThis.require = prior;
    }
  }
} finally {
  globalThis.require = savedRequire;
}

Also consider avoiding process-global state entirely by passing a scoped require into plugin evaluation if the loader/runtime supports it.


Analyzed PR: #13109 at commit e894379

Last updated on: 2026-03-05T04:22:20Z

@mcaxtr mcaxtr force-pushed the fix/12854-cjs-require-extensions branch from fbc0d97 to 5008a05 Compare March 5, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Extensions using CJS dependencies fail with "require is not defined"

2 participants