Skip to content

Commit 9c6c2a4

Browse files
moeghassiCopilot
andcommitted
module: fail closed when reading package.json is denied
GetPackageJSON() now reports unreadable manifests, but treats a directory named package.json as absent and reports other read failures as ERR_INVALID_PACKAGE_CONFIG. Only ENOENT and ENOTDIR now mean "no package.json here". Any other read error is treated as a security-relevant signal and throws ERR_ACCESS_DENIED instead of falling back, so a denied or quarantined manifest aborts resolution for every package style (default index.js, "main", and "exports"). The failure is intentionally not negative-cached so the deny is not latched for the lifetime of the process. The parent-scope walks and FindNodeModulesPackage now stop when an exception is pending so the thrown error propagates instead of being swallowed by continuing up the tree. Cover directory manifests, retry after a failed read, and unreadable files with regression tests. Signed-off-by: Moe Ghasemi <moeghasemi@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c9e5a501-399c-443b-9241-2c71246b185f
1 parent 678c555 commit 9c6c2a4

2 files changed

Lines changed: 115 additions & 12 deletions

File tree

‎src/node_modules.cc‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -258,18 +258,15 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON(
258258
// No need to exclude BOM since simdjson will skip it.
259259
int read_error = ReadFileSync(&package_config.raw_json, path.data());
260260
if (read_error < 0) {
261-
// No file at this path, a path component that is not a directory, or a
262-
// "package.json" that is itself a directory all mean there is no package
263-
// config here. Any other failure means a package.json is present but
264-
// could not be read. Treating that as absent silently drops fields such
265-
// as "exports" and "type", which can resolve a specifier to a different
266-
// file, so surface the read error instead of continuing.
267-
if (read_error != UV_ENOENT && read_error != UV_ENOTDIR &&
268-
read_error != UV_EISDIR) {
269-
THROW_ERR_INVALID_PACKAGE_CONFIG(realm->isolate(),
270-
"Cannot read package config %s: %s.",
271-
path.data(),
272-
uv_strerror(read_error));
261+
// Only a missing file or a non-directory path component means no config.
262+
// Other failures, including a directory named package.json, must stop
263+
// resolution rather than silently allowing index.js fallback. Do not cache
264+
// these failures, since read access may be restored later.
265+
if (read_error != UV_ENOENT && read_error != UV_ENOTDIR) {
266+
THROW_ERR_ACCESS_DENIED(realm->isolate(),
267+
"Cannot read package config %s: %s",
268+
path.data(),
269+
uv_strerror(read_error));
273270
return nullptr;
274271
}
275272

@@ -406,6 +403,12 @@ const BindingData::PackageConfig* BindingData::TraverseParent(
406403

407404
auto package_json =
408405
GetPackageJSON(realm, ConvertPathToUTF8(package_json_path), nullptr);
406+
// A denied/quarantined manifest (or an invalid package config) throws;
407+
// stop walking so the pending exception propagates instead of being
408+
// swallowed by continuing up the tree.
409+
if (realm->isolate()->HasPendingException()) [[unlikely]] {
410+
return nullptr;
411+
}
409412
if (package_json != nullptr) {
410413
return package_json;
411414
}
@@ -459,6 +462,9 @@ const BindingData::PackageConfig* BindingData::FindNodeModulesPackage(
459462

460463
auto package_json =
461464
GetPackageJSON(realm, ConvertPathToUTF8(package_json_path), nullptr);
465+
if (realm->isolate()->HasPendingException()) [[unlikely]] {
466+
return nullptr;
467+
}
462468
if (package_json != nullptr) {
463469
if (!package_json->is_private.value_or(false)) {
464470
// A publishable (non-private) package encloses the file.
@@ -598,6 +604,12 @@ void BindingData::GetPackageScopeConfig(
598604
error_context.specifier = resolved.ToString();
599605
auto package_json =
600606
GetPackageJSON(realm, file_path_buf.ToStringView(), &error_context);
607+
// A denied/quarantined manifest (or an invalid package config) throws;
608+
// stop walking so the pending exception propagates instead of being
609+
// swallowed by continuing up the tree.
610+
if (realm->isolate()->HasPendingException()) [[unlikely]] {
611+
return;
612+
}
601613
if (package_json != nullptr) {
602614
if constexpr (return_only_type) {
603615
Local<Value> value;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict';
2+
3+
// This test verifies that when reading a package's `package.json` fails with a
4+
// non-ENOENT/ENOTDIR error (e.g. EACCES, EISDIR, or an anti-malware/EDR deny),
5+
// the module loader fails closed with ERR_ACCESS_DENIED instead
6+
// of treating the manifest as absent and silently falling back to `index.js`.
7+
//
8+
// The unreadable-file case relies on chmod(0), which only produces EACCES
9+
// for a non-root user on POSIX systems.
10+
11+
const common = require('../common');
12+
const assert = require('assert');
13+
const fs = require('fs');
14+
const path = require('path');
15+
const tmpdir = require('../common/tmpdir');
16+
17+
tmpdir.refresh();
18+
19+
{
20+
const dirPkg = path.join(tmpdir.path, 'node_modules', 'directory-manifest');
21+
const manifest = path.join(dirPkg, 'package.json');
22+
const marker = path.join(tmpdir.path, 'directory-loaded.marker');
23+
fs.mkdirSync(manifest, { recursive: true });
24+
fs.writeFileSync(
25+
path.join(dirPkg, 'index.js'),
26+
`require('fs').writeFileSync(${JSON.stringify(marker)}, 'loaded');\n`);
27+
28+
// Some platforms can read directories, so only test this error when the
29+
// filesystem actually reports EISDIR.
30+
let directoryReadError;
31+
try {
32+
fs.readFileSync(manifest);
33+
} catch (err) {
34+
directoryReadError = err;
35+
}
36+
if (directoryReadError) {
37+
assert.strictEqual(directoryReadError.code, 'EISDIR');
38+
assert.throws(() => require(dirPkg), { code: 'ERR_ACCESS_DENIED' });
39+
assert.strictEqual(fs.existsSync(marker), false);
40+
41+
// A failed read must not be cached as an absent package config.
42+
fs.rmdirSync(manifest);
43+
fs.writeFileSync(manifest, '{"main":"real.js"}');
44+
fs.writeFileSync(path.join(dirPkg, 'real.js'), 'module.exports = 42;\n');
45+
assert.strictEqual(require(dirPkg), 42);
46+
assert.strictEqual(fs.existsSync(marker), false);
47+
}
48+
}
49+
50+
if (common.isWindows) {
51+
common.skip('chmod(0) does not produce EACCES on Windows');
52+
}
53+
if (!process.getuid || process.getuid() === 0) {
54+
common.skip('cannot produce EACCES as root');
55+
}
56+
57+
const pkgDir = path.join(tmpdir.path, 'node_modules', 'evil');
58+
fs.mkdirSync(pkgDir, { recursive: true });
59+
60+
// A default-`index.js` package: before the fix, a denied manifest was swallowed
61+
// (index.js fallback) and this code would run anyway.
62+
const indexPath = path.join(pkgDir, 'index.js');
63+
const markerPath = path.join(tmpdir.path, 'loaded.marker');
64+
fs.writeFileSync(
65+
indexPath,
66+
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'loaded');\n`);
67+
68+
const pkgJsonPath = path.join(pkgDir, 'package.json');
69+
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'evil', version: '1.0.0' }));
70+
71+
// Deny reads of the manifest to simulate a quarantine/anti-malware block.
72+
fs.chmodSync(pkgJsonPath, 0o000);
73+
74+
// Sanity check: the deny actually took effect (otherwise the test is moot).
75+
try {
76+
fs.readFileSync(pkgJsonPath);
77+
common.skip('environment allowed reading a chmod(0) file');
78+
} catch (err) {
79+
assert.strictEqual(err.code, 'EACCES');
80+
}
81+
82+
try {
83+
assert.throws(
84+
() => require(pkgDir),
85+
{ code: 'ERR_ACCESS_DENIED' },
86+
'requiring a package whose package.json read is denied must fail closed');
87+
88+
assert.strictEqual(fs.existsSync(markerPath), false);
89+
} finally {
90+
fs.chmodSync(pkgJsonPath, 0o644);
91+
}

0 commit comments

Comments
 (0)