Skip to content

Commit 9759adf

Browse files
committed
module: fail closed when reading package.json is denied
GetPackageJSON() treated every negative return from ReadFileSync() the same way: it cached a negative result and reported the manifest as absent. A denied read (EACCES/EPERM, or an anti-malware/EDR block that surfaces as a failed open) was therefore indistinguishable from ENOENT, so resolution silently fell back to index.js and the package loaded anyway. This makes it impossible for an on-endpoint scanner to stop a require()/import of a quarantined package by denying its manifest. 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 two parent-scope walks (TraverseParent and GetPackageScopeConfig) now stop when an exception is pending so the thrown error propagates instead of being swallowed by continuing up the tree. Signed-off-by: Moe Ghasemi <moeghasemi@microsoft.com>
1 parent c8fa0b1 commit 9759adf

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

‎src/node_modules.cc‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,26 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON(
111111
PackageConfig package_config{};
112112
package_config.file_path = path;
113113
// No need to exclude BOM since simdjson will skip it.
114-
if (ReadFileSync(&package_config.raw_json, path.data()) < 0) {
115-
// Add `nullopt` to the package config cache so that we don't
116-
// need to open and attempt to read this path again
117-
binding_data->package_configs_.insert({std::string(path), std::nullopt});
114+
int read_err = ReadFileSync(&package_config.raw_json, path.data());
115+
if (read_err < 0) {
116+
// A missing file (ENOENT) or a non-directory path component (ENOTDIR)
117+
// legitimately means "there is no package.json here". Treat it as absent
118+
// and cache the negative result so we don't try to open this path again.
119+
if (read_err == UV_ENOENT || read_err == UV_ENOTDIR) {
120+
binding_data->package_configs_.insert({std::string(path), std::nullopt});
121+
return nullptr;
122+
}
123+
// Any other failure (e.g. EACCES/EPERM, or an anti-malware/EDR deny that
124+
// surfaces as a blocked read) is a security-relevant signal, not proof of
125+
// absence. Fail closed: throw instead of silently falling back to the
126+
// legacy `index.js` resolution, so a denied or quarantined manifest cannot
127+
// be treated as "no package.json" and let the package load anyway.
128+
// Intentionally not negative-cached so the deny isn't latched for the
129+
// lifetime of the process.
130+
THROW_ERR_ACCESS_DENIED(realm->isolate(),
131+
"Cannot read package config %s: %s",
132+
path.data(),
133+
uv_strerror(read_err));
118134
return nullptr;
119135
}
120136

@@ -325,6 +341,12 @@ const BindingData::PackageConfig* BindingData::TraverseParent(
325341

326342
auto package_json =
327343
GetPackageJSON(realm, ConvertPathToUTF8(package_json_path), nullptr);
344+
// A denied/quarantined manifest (or an invalid package config) throws;
345+
// stop walking so the pending exception propagates instead of being
346+
// swallowed by continuing up the tree.
347+
if (realm->isolate()->HasPendingException()) [[unlikely]] {
348+
return nullptr;
349+
}
328350
if (package_json != nullptr) {
329351
return package_json;
330352
}
@@ -435,6 +457,12 @@ void BindingData::GetPackageScopeConfig(
435457
error_context.specifier = resolved.ToString();
436458
auto package_json =
437459
GetPackageJSON(realm, file_path_buf.ToStringView(), &error_context);
460+
// A denied/quarantined manifest (or an invalid package config) throws;
461+
// stop walking so the pending exception propagates instead of being
462+
// swallowed by continuing up the tree.
463+
if (realm->isolate()->HasPendingException()) [[unlikely]] {
464+
return;
465+
}
438466
if (package_json != nullptr) {
439467
if constexpr (return_only_type) {
440468
Local<Value> value;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use strict';
2+
3+
// This test verifies that when reading a package's `package.json` fails with a
4+
// non-ENOENT error (e.g. EACCES, or an anti-malware/EDR deny that surfaces as a
5+
// blocked read), 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+
// It relies on chmod(0) to make the file unreadable, which only produces EACCES
9+
// for a non-root user on POSIX systems, so it is skipped on Windows and when
10+
// running as root.
11+
12+
const common = require('../common');
13+
14+
if (common.isWindows) {
15+
common.skip('chmod(0) does not produce EACCES on Windows');
16+
}
17+
if (!process.getuid || process.getuid() === 0) {
18+
common.skip('cannot produce EACCES as root');
19+
}
20+
21+
const assert = require('assert');
22+
const fs = require('fs');
23+
const path = require('path');
24+
const tmpdir = require('../common/tmpdir');
25+
26+
tmpdir.refresh();
27+
28+
const pkgDir = path.join(tmpdir.path, 'node_modules', 'evil');
29+
fs.mkdirSync(pkgDir, { recursive: true });
30+
31+
// A default-`index.js` package: before the fix, a denied manifest was swallowed
32+
// (index.js fallback) and this code would run anyway.
33+
const indexPath = path.join(pkgDir, 'index.js');
34+
const markerPath = path.join(tmpdir.path, 'loaded.marker');
35+
fs.writeFileSync(
36+
indexPath,
37+
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'loaded');\n`);
38+
39+
const pkgJsonPath = path.join(pkgDir, 'package.json');
40+
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'evil', version: '1.0.0' }));
41+
42+
// Deny reads of the manifest to simulate a quarantine/anti-malware block.
43+
fs.chmodSync(pkgJsonPath, 0o000);
44+
45+
// Sanity check: the deny actually took effect (otherwise the test is moot).
46+
try {
47+
fs.readFileSync(pkgJsonPath);
48+
common.skip('environment allowed reading a chmod(0) file');
49+
} catch (err) {
50+
assert.strictEqual(err.code, 'EACCES');
51+
}
52+
53+
assert.throws(
54+
() => require(pkgDir),
55+
(err) => {
56+
assert.strictEqual(err.code, 'ERR_ACCESS_DENIED');
57+
return true;
58+
},
59+
'requiring a package whose package.json read is denied must fail closed');
60+
61+
// The index.js fallback must NOT have executed.
62+
assert.strictEqual(
63+
fs.existsSync(markerPath), false,
64+
'index.js fallback ran despite the manifest being denied');
65+
66+
// Restore perms so tmpdir cleanup can remove the file.
67+
fs.chmodSync(pkgJsonPath, 0o644);

0 commit comments

Comments
 (0)