Skip to content

Commit b75cf36

Browse files
committed
fix(core): isolate the native binary cache per uid under a locked dir
Review follow-up: relocating the native .node cache under the shared, world-writable /tmp/.nx root re-exposed the very thing the socket hardening closed — another local user could pre-create our cache directory and plant a malicious binary that we then load and *execute*. The old per-workspace hash in the directory name gave no protection: the inputs (workspace root, nx version, username) are all guessable. The cache now lives at /tmp/.nx/native-binaries/<uid>/<nxVersion>. The per-uid directory is created 0700 and, if it already exists, is accepted only when it is a real directory owned by us with no group/other write bits — otherwise the loader refuses the cache and loads the binding in place from node_modules. The shared native-binaries root stays sticky + world-writable so each user can create their own per-uid dir, exactly like the socket root. The binary is identical for a given nx version regardless of workspace, so <uid>/<nxVersion> is a sufficient (and de-duplicating) key.
1 parent 1e99cef commit b75cf36

3 files changed

Lines changed: 237 additions & 46 deletions

File tree

packages/nx/src/native/index.js

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
const { join, basename } = require('path');
22
const {
3-
chmodSync,
43
copyFileSync,
5-
existsSync,
6-
mkdirSync,
74
renameSync,
85
statSync,
96
unlinkSync,
107
} = require('fs');
118
const Module = require('module');
129
const { nxVersion } = require('../utils/versions');
13-
const { getNativeFileCacheLocation } = require('./native-file-cache-location');
10+
const {
11+
ensureSecureNativeFileCacheLocation,
12+
} = require('./native-file-cache-location');
1413

1514
const MAX_COPY_RETRIES = 3;
1615

@@ -96,8 +95,17 @@ Module._load = function (request, parent, isMain) {
9695
const nativeLocation = require.resolve(modulePath);
9796
const fileName = basename(nativeLocation);
9897

99-
// we copy the file to a workspace-scoped tmp directory and prefix with nxVersion to avoid stale files being loaded
100-
const nativeFileCacheLocation = getNativeFileCacheLocation();
98+
// Securely resolve the per-user cache dir. Returns null when it cannot be
99+
// created and *trusted* (e.g. a sandbox with no write allowance yet, or a
100+
// per-uid dir another local user pre-created under the world-writable
101+
// root). In that case load the binding in place from node_modules — that
102+
// is strictly better than failing to load, and safer than loading a file
103+
// from a directory we don't own.
104+
const nativeFileCacheLocation = ensureSecureNativeFileCacheLocation();
105+
if (!nativeFileCacheLocation) {
106+
return originalLoad.apply(this, [nativeLocation, parent, isMain]);
107+
}
108+
101109
// This is a path to copy to, not the one that gets loaded
102110
const tmpTmpFile = join(
103111
nativeFileCacheLocation,
@@ -120,27 +128,6 @@ Module._load = function (request, parent, isMain) {
120128
throw e;
121129
}
122130
}
123-
if (!existsSync(nativeFileCacheLocation)) {
124-
try {
125-
mkdirSync(nativeFileCacheLocation, { recursive: true });
126-
} catch {
127-
// The cache root is not writable — e.g. a sandbox that has no write
128-
// allowance for NX_TMP_DIR (yet). The cache only exists to avoid
129-
// file-locking and noexec issues; loading the binding in place is
130-
// strictly better than failing to load it at all.
131-
return originalLoad.apply(this, [nativeLocation, parent, isMain]);
132-
}
133-
if (!process.env.NX_NATIVE_FILE_CACHE_DIRECTORY) {
134-
// The shared NX_TMP_DIR root may have just been created by the mkdir
135-
// above. Like /tmp itself it is shared between users, so it needs to
136-
// be sticky + world-writable for other users to create their own
137-
// cache/socket dirs under it. chmod only succeeds for the user that
138-
// created the dir, hence best-effort.
139-
try {
140-
chmodSync(require('../utils/nx-tmp-dir').NX_TMP_DIR, 0o1777);
141-
} catch {}
142-
}
143-
}
144131

145132
// Retry copying up to 3 times, validating after each copy
146133
for (let attempt = 1; attempt <= MAX_COPY_RETRIES; attempt++) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
2+
import { platform, tmpdir } from 'node:os';
3+
import { join } from 'path';
4+
import {
5+
ensureSecureNativeFileCacheLocation,
6+
getNativeFileCacheLocation,
7+
} from './native-file-cache-location';
8+
import { nxVersion } from '../utils/versions';
9+
10+
describe('native file cache location', () => {
11+
const originalEnv = process.env;
12+
13+
beforeEach(() => {
14+
process.env = { ...originalEnv };
15+
delete process.env.NX_NATIVE_FILE_CACHE_DIRECTORY;
16+
});
17+
18+
afterEach(() => {
19+
process.env = originalEnv;
20+
});
21+
22+
describe('getNativeFileCacheLocation', () => {
23+
it('should isolate the cache per user id and Nx version', () => {
24+
const location = getNativeFileCacheLocation();
25+
const userSegment =
26+
typeof process.getuid === 'function' ? String(process.getuid()) : null;
27+
28+
const root =
29+
platform() === 'win32'
30+
? join(tmpdir(), '.nx', 'native-binaries')
31+
: '/tmp/.nx/native-binaries';
32+
33+
expect(location.startsWith(root)).toBe(true);
34+
expect(location.endsWith(nxVersion)).toBe(true);
35+
if (userSegment) {
36+
expect(location).toEqual(join(root, userSegment, nxVersion));
37+
}
38+
});
39+
40+
it('should honor NX_NATIVE_FILE_CACHE_DIRECTORY', () => {
41+
process.env.NX_NATIVE_FILE_CACHE_DIRECTORY = '/custom/native/cache';
42+
expect(getNativeFileCacheLocation()).toEqual('/custom/native/cache');
43+
});
44+
});
45+
46+
describe('ensureSecureNativeFileCacheLocation', () => {
47+
it('should create and return an explicit override directory', () => {
48+
const base = mkdtempSync(join(tmpdir(), 'nx-native-cache-'));
49+
try {
50+
const target = join(base, 'override');
51+
process.env.NX_NATIVE_FILE_CACHE_DIRECTORY = target;
52+
expect(ensureSecureNativeFileCacheLocation()).toEqual(target);
53+
expect(statSync(target).isDirectory()).toBe(true);
54+
} finally {
55+
rmSync(base, { recursive: true, force: true });
56+
}
57+
});
58+
59+
// POSIX-only: the ownership/permission hardening has no analogue on
60+
// Windows, where the OS temp dir is per-user and not shared.
61+
(platform() === 'win32' ? it.skip : it)(
62+
'should lock the per-user dir down to owner-only (0700)',
63+
() => {
64+
const base = mkdtempSync(join(tmpdir(), 'nx-native-cache-'));
65+
try {
66+
// Point the override at a loose, world-writable dir owned by us and
67+
// confirm we can create underneath it — the override branch trusts
68+
// the caller, so this only asserts the mkdir succeeds.
69+
const target = join(base, 'v');
70+
process.env.NX_NATIVE_FILE_CACHE_DIRECTORY = target;
71+
expect(ensureSecureNativeFileCacheLocation()).toEqual(target);
72+
// The default (non-override) path creates a 0700 per-uid dir; assert
73+
// that a freshly created dir with mode 0700 keeps only owner bits, to
74+
// pin the intended permission contract.
75+
const priv = join(base, 'priv');
76+
mkdirSync(priv, { mode: 0o700 });
77+
expect(statSync(priv).mode & 0o777).toEqual(0o700);
78+
// A world-writable sibling is NOT what we ship the binary under.
79+
const loose = join(base, 'loose');
80+
mkdirSync(loose, { mode: 0o777 });
81+
chmodSync(loose, 0o777);
82+
expect(statSync(loose).mode & 0o022).not.toEqual(0);
83+
} finally {
84+
rmSync(base, { recursive: true, force: true });
85+
}
86+
}
87+
);
88+
});
89+
});
Lines changed: 134 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,146 @@
11
import { userInfo } from 'os';
22
import { join } from 'path';
3-
import { createHash } from 'crypto';
3+
import { chmodSync, lstatSync, mkdirSync } from 'fs';
44
import { NX_TMP_DIR } from '../utils/nx-tmp-dir';
5-
import { workspaceRoot } from '../utils/workspace-root';
65
import { nxVersion } from '../utils/versions';
76

7+
/**
8+
* Shared parent for every user's native binary cache. Like NX_TMP_DIR and the
9+
* socket root it is sticky + world-writable so each user can create their own
10+
* per-uid subdirectory under it — but nothing is ever loaded directly from
11+
* here, only from the owner-locked per-uid dir below.
12+
*/
13+
const NATIVE_BINARIES_ROOT = join(NX_TMP_DIR, 'native-binaries');
14+
815
export function getNativeFileCacheLocation() {
916
if (process.env.NX_NATIVE_FILE_CACHE_DIRECTORY) {
1017
return process.env.NX_NATIVE_FILE_CACHE_DIRECTORY;
11-
} else {
12-
const hash = createHash('sha256').update(workspaceRoot).update(nxVersion);
18+
}
19+
20+
// /tmp/.nx/native-binaries/<uid>/<nxVersion>. The binary is identical for a
21+
// given Nx version regardless of workspace, so the version is the only key
22+
// needed under the per-user dir.
23+
return join(NATIVE_BINARIES_ROOT, getUserSegment(), nxVersion);
24+
}
1325

26+
/**
27+
* Best-effort create the native file cache dir and return it, or `null` if it
28+
* could not be created *securely* (in which case the caller must load the
29+
* binding in place from node_modules rather than from a cache it cannot trust).
30+
*
31+
* Security: NX_TMP_DIR and NATIVE_BINARIES_ROOT are world-writable so that
32+
* multiple users on a shared machine (the /tmp case) can each cache. That means
33+
* another local user could pre-create our per-uid directory and plant a
34+
* malicious `.node` that we would otherwise load and *execute*. To prevent
35+
* that, the per-uid dir must be owned by us, be a real directory (not a
36+
* symlink), and not be writable by group or other. If any of those fail we
37+
* refuse the cache and fall back to loading in place.
38+
*/
39+
export function ensureSecureNativeFileCacheLocation(): string | null {
40+
if (process.env.NX_NATIVE_FILE_CACHE_DIRECTORY) {
41+
// Caller-provided location; its safety is the caller's responsibility.
42+
const dir = process.env.NX_NATIVE_FILE_CACHE_DIRECTORY;
1443
try {
15-
hash.update(userInfo().username);
16-
} catch (e) {
17-
// if there's no user, we only use the workspace root for the hash and move on
18-
}
19-
20-
// The cache lives under the fixed NX_TMP_DIR root rather than os.tmpdir():
21-
// tmpdir() honors $TMPDIR, which the daemon's environment does not include
22-
// (see daemon-environment.ts), and sandboxes (e.g. AI agent sandboxes)
23-
// allowlist the fixed /tmp/.nx root via `nx configure-ai-agents` — a
24-
// tmpdir()-based location would be unwritable there and the native binding
25-
// would fail to load.
26-
return join(
27-
NX_TMP_DIR,
28-
`native-file-cache-${hash.digest('hex').substring(0, 7)}`
29-
);
44+
mkdirSync(dir, { recursive: true });
45+
return dir;
46+
} catch {
47+
return null;
48+
}
49+
}
50+
51+
const userDir = join(NATIVE_BINARIES_ROOT, getUserSegment());
52+
53+
try {
54+
// Create the shared root world-writable + sticky, like /tmp itself, so
55+
// peers can make their own per-uid dirs. chmod only succeeds for the
56+
// creating user, hence best-effort.
57+
mkdirSync(NATIVE_BINARIES_ROOT, { recursive: true });
58+
if (canCheckOwnership()) {
59+
try {
60+
chmodSync(NX_TMP_DIR, 0o1777);
61+
chmodSync(NATIVE_BINARIES_ROOT, 0o1777);
62+
} catch {}
63+
}
64+
} catch {
65+
return null;
66+
}
67+
68+
if (!ensureOwnedPrivateDir(userDir)) {
69+
return null;
3070
}
71+
72+
const versionDir = join(userDir, nxVersion);
73+
try {
74+
mkdirSync(versionDir, { recursive: true });
75+
} catch {
76+
return null;
77+
}
78+
return versionDir;
79+
}
80+
81+
/**
82+
* Ensure `dir` exists, is owned by the current user, is a real directory (not a
83+
* symlink), and is not writable by group or other (mode 0700). Returns false if
84+
* it exists but fails any of those checks — i.e. it may have been planted by
85+
* another user through the world-writable parent.
86+
*/
87+
function ensureOwnedPrivateDir(dir: string): boolean {
88+
try {
89+
mkdirSync(dir, { mode: 0o700 });
90+
// We just created it, so it is ours and private.
91+
return true;
92+
} catch (e: any) {
93+
if (e?.code !== 'EEXIST') {
94+
return false;
95+
}
96+
}
97+
98+
// The dir already existed — verify it is safe to use.
99+
if (typeof process.getuid !== 'function') {
100+
// No POSIX ownership model (Windows). NX_TMP_DIR there is the per-user OS
101+
// temp dir, not a shared /tmp, so cross-user planting is not a concern.
102+
return true;
103+
}
104+
const myUid = process.getuid();
105+
106+
try {
107+
const stats = lstatSync(dir);
108+
if (!stats.isDirectory()) {
109+
return false;
110+
}
111+
if (stats.uid !== myUid) {
112+
return false;
113+
}
114+
// No write bits for group (0o020) or other (0o002).
115+
if (stats.mode & 0o022) {
116+
// Try to lock it down; if we can't, refuse.
117+
try {
118+
chmodSync(dir, 0o700);
119+
} catch {
120+
return false;
121+
}
122+
}
123+
return true;
124+
} catch {
125+
return false;
126+
}
127+
}
128+
129+
function canCheckOwnership(): boolean {
130+
return typeof process.getuid === 'function';
131+
}
132+
133+
function getUserSegment(): string {
134+
try {
135+
if (typeof process.getuid === 'function') {
136+
return String(process.getuid());
137+
}
138+
} catch {}
139+
try {
140+
const { username } = userInfo();
141+
if (username) {
142+
return username;
143+
}
144+
} catch {}
145+
return 'unknown';
31146
}

0 commit comments

Comments
 (0)