node:module: check exceptions from the parent lookup in the native require resolver - #38099
node:module: check exceptions from the parent lookup in the native require resolver#38099robobun wants to merge 1 commit into
Conversation
…quire resolver functionImportMeta__resolveSyncPrivate called requireMap()->get() and coerced the parent id with toWTFString() without checking for exceptions before calling an overridden Module._resolveFilename, and coerced the specifier and parent the same way in the virtual-module branch. A parent whose filename does not convert to a string then reached the override call with an exception already pending (debug assertion), and any override at all tripped BUN_JSC_validateExceptionChecks on the unchecked JSMap::get. Add RETURN_IF_EXCEPTION after each of those calls and cover the override and virtual-module paths with a subprocess test run under the validator.
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 6:31 AM PT - Aug 13th, 2026
✅ @robobun, your commit 016b8e5f190a62b69bac1864df53c18f7b72f7c6 passed in 🧪 To try this PR locally: bunx bun-pr 38099That installs a local version of the PR into your bun-38099 --bun |
|
Status: fix pushed, waiting on CI and review. Reproduced on a debug build of main with the existing fixture: and, without the validator, a parent whose |
There was a problem hiding this comment.
LGTM — four mechanical RETURN_IF_EXCEPTION insertions after calls that can throw, matching the pattern already used elsewhere in the same function.
What was reviewed:
- The four new checks each follow a call that can leave an exception pending (
toWTFStringon user-controlled values,JSMap::get);scopeis the function's existingDECLARE_THROW_SCOPE. - Extracting
from.toWTFString()to a local preserves the original evaluation order (moduleString first, then fromString) — no happy-path behavior change. - The sibling
functionImportMeta__resolveSyncwas intentionally left alone; the PR description's reasoning (itsfromis always string-or-undefined andmoduleName.isString()is checked first) matches the code. - Test follows harness conventions (
tempDir,bunEnvspread, concurrent pipe drain, singletoEqualon transcript+validator lines+exit code) and covers both the override and virtual-module branches, including the negative assertion that the override is never entered when the parent coercion throws.
Extended reasoning...
Overview
This PR adds four RETURN_IF_EXCEPTION(scope, {}) checks in functionImportMeta__resolveSyncPrivate (src/jsc/bindings/ImportMetaObject.cpp): after the specifier and parent toWTFString coercions in the virtual-module branch, after requireMap()->get(), and after the parent-id toWTFString in the _resolveFilename override branch. It also adds a subprocess test in test/js/node/module/node-module-module.test.js that runs under BUN_JSC_validateExceptionChecks=1 and exercises all four sites.
Security risks
None. This adds exception propagation guards; it does not change validation, permissions, or any security-sensitive path. The only behavioral change is that a throwing toString on a fake parent's filename now propagates cleanly instead of entering the override with an exception already pending.
Level of scrutiny
Low-to-medium. The C++ change is a textbook JSC exception-check fix — the exact pattern the repo's review guidance mandates ("Exception checks after every call that can enter JS"), and the same pattern already used a few lines below for profiledCall, result.toString, and the paths loop. Each inserted check is placed immediately after the throwing call and before its result is used or the next scope is created. The virtual-module branch refactor (pulling from.toWTFString() out of the argument position) preserves evaluation order.
Other factors
- The PR description enumerates the other
requireMap()->get()call sites in the tree that already check, so this brings the outlier in line. - The similar-looking branch in
functionImportMeta__resolveSyncis deliberately not touched, and the stated reason (itsfromis only ever a string orjsUndefined(), andmoduleName.isString()gates the coercion) checks out against the preloaded source. - The new test uses
tempDir, spreadsbunEnv, drains stdout/stderr/exited concurrently, asserts a combined object, and surfaces the validator's "This scope can throw" / "But the exception was unchecked" lines so a regression points at the offending scope rather than just a truncated transcript. It also assertsoverrideCalls: 0to prove the override is not entered when the parent coercion throws, and re-verifies the happy path (virtual module still loads = true) after the throwing cases. - No prior reviews or outstanding comments on the PR.
Problem
require("module")._resolveFilenameand then callsrequire()aborts under JSC's exception-check validator:functionImportMeta__resolveSyncPrivate(src/jsc/bindings/ImportMetaObject.cpp) is the native resolver behindrequire()andrequire.resolve(). In its_resolveFilenameoverride branch it callsglobalObject->requireMap()->get(globalObject, from)(line 232) andparentID.toWTFString(globalObject)(line 244) with noRETURN_IF_EXCEPTIONafter either. Every otherrequireMap()->get()call site in the tree checks (JSCommonJSModule.cpp:1511, :1582, :1632, BunPlugin.cpp:689).fromisthis.filenameof whateverrequirewas invoked on, so thetoWTFStringgenuinely throws when the parent's filename is an object with a throwingtoString(for exampleModule.prototype.require.call(fakeModule, id)). A plain debug build then calls the override with that exception already pending and asserts:executeCallImplbails out on the pending exception before entering the override.from(and the specifier) withtoWTFStringand is unchecked in the same way; with a virtual module registered (Bun.pluginbuild.module()ormock.module()) the same throwing parent trips the validator there instead (toWTFStringSlowCaseunchecked as of the next scope inBun__resolveSync).test/js/node/module/node-module-module.test.js, which spawns the_resolveFilenamefixture, is listed intest/no-validate-exceptions.txt.Fix
RETURN_IF_EXCEPTION(scope, {})after the four exception-capable calls infunctionImportMeta__resolveSyncPrivate: the specifier and parent coercions in the virtual-module branch, andrequireMap()->get()and the parent-id coercion in the override branch.scopeis the function'sDECLARE_THROW_SCOPE; the rest of the function (profiledCall,result.toString, thepathsloop) already follows this pattern.JSMap::gethashes the key under a throw scope;toWTFStringon a non-string runs usertoString), and the only sensible outcome is to propagate that exception to therequire()caller. The checks do exactly that and change nothing on the non-throwing path; the eager coercion order is kept as it was.functionImportMeta__resolveSync(the publicimport.meta.resolveSync) has a similar-looking virtual-module branch but is not touched: it only ever passes a string orundefinedasfromand checksmoduleName.isString()first, so neither coercion there can run user code.test/js/node/module/node-module-module.test.js, new test_resolveFilename override and virtual modules propagate parent lookup exceptions): it spawns a fixture underBUN_JSC_validateExceptionChecks=1that exercises the override with a cached parent,require.resolve(..., { paths })through the override, a throwing parent against the override (asserting the override is never called), and the same parent against the virtual-module branch for both a matching and a non-matching specifier, and asserts the transcript plus the validator's report lines plus exit code in onetoEqual.uncheckedScopesnaminggetImpl @ JSOrderedHashTable.h:180.resolveFilenameOverwrite.cjsand the brief's one-liner repro also run clean under the validator; the throwing-parent repro no longer asserts.test/js/node/module/module-resolve-filename-paths.test.jsandtest/js/bun/plugin/plugins.test.ts(virtual modules viarequire()) pass on the fixed build.node-module-module.test.jsstays intest/no-validate-exceptions.txtfor now: with this change applied, the file's remaining validator failures are the ones owned by node:module: check exceptions from jsString/jsSubstring in Module.wrap and new Module() #34745 (Module.wrap) and theModule.runMainoverride path (reported separately), so the entry can be dropped once those land too._resolveFilename), which re-indents this block without adding these checks, and with node:module: pass Node-compatible arguments to an overridden Module._resolveFilename #34102, which replaces the parent lookup in the override branch with arguments passed from JS (the virtual-module hunk is unaffected by it); whichever lands second needs a trivial rebase.Background
RETURN_IF_EXCEPTION: JSC host code declares aThrowScopeand must check for a pending exception after every call that may throw (RETURN_IF_EXCEPTION), instead of continuing to run with the exception set. Calling back into JS with an exception pending is a debug assertion (assertNoException), and in release it makes later throws silently replace or reorder the original error.BUN_JSC_validateExceptionChecks=1: debug-only JSC mode in which every scope that could have thrown records that the caller owes a check; creating the next scope (or destroying the current one) without that check aborts the process and prints the two locations shown above. Bun's ASAN CI lanes run tests with it enabled except for files listed intest/no-validate-exceptions.txt.functionImportMeta__resolveSyncPrivate: the$resolveSyncbuiltin used by the CommonJSrequire/require.resolveimplementation insrc/js/builtins/CommonJS.ts. It receives the specifier and the parent'sfilename, first consults runtime virtual modules (Bun.pluginbuild.module()/mock.module()), then an overriddenModule._resolveFilenameif one was installed, then the native resolver.