Skip to content

Commit 4554ce7

Browse files
authored
fix(builtin): don't allow symlinks to escape or enter bazel managed node_module folders (#1800)
1 parent 385cc61 commit 4554ce7

16 files changed

Lines changed: 407 additions & 42 deletions

File tree

internal/node/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ filegroup(
4646
visibility = ["//:__pkg__"],
4747
)
4848

49+
# To update node_patches.js run:
50+
# bazel run //internal/node:checked_in_node_patches.accept
4951
golden_file_test(
5052
name = "checked_in_node_patches",
5153
actual = "//packages/node-patches:bundle",

internal/node/launcher.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,37 @@ fi
217217
# Bazel always sets the PWD to execroot/my_wksp so we go up one directory.
218218
export BAZEL_PATCH_ROOT=$(dirname $PWD)
219219

220+
# Set all bazel managed node_modules directories as guarded so no symlinks may
221+
# escape and no symlinks may enter
222+
if [[ "$PWD" == *"/bazel-out/"* ]]; then
223+
# We in runfiles, find the execroot.
224+
# Look for `bazel-out` which is used to determine the the path to `execroot/my_wksp`. This works in
225+
# all cases including on rbe where the execroot is a path such as `/b/f/w`. For example, when in
226+
# runfiles on rbe, bazel runs the process in a directory such as
227+
# `/b/f/w/bazel-out/k8-fastbuild/bin/path/to/pkg/some_test.sh.runfiles/my_wksp`. From here we can
228+
# determine the execroot `b/f/w` by finding the first instance of bazel-out.
229+
readonly bazel_out="/bazel-out/"
230+
readonly rest=${PWD#*$bazel_out}
231+
readonly index=$(( ${#PWD} - ${#rest} - ${#bazel_out} ))
232+
if [[ ${index} < 0 ]]; then
233+
echo "No 'bazel-out' folder found in path '${PWD}'!"
234+
exit 1
235+
fi
236+
readonly execroot=${PWD:0:${index}}
237+
export BAZEL_PATCH_GUARDS="${execroot}/node_modules"
238+
else
239+
# We are in execroot, linker node_modules is in the PWD
240+
export BAZEL_PATCH_GUARDS="${PWD}/node_modules"
241+
fi
242+
if [[ -n "${BAZEL_NODE_MODULES_ROOT:-}" ]]; then
243+
if [[ "${BAZEL_NODE_MODULES_ROOT}" != "${BAZEL_WORKSPACE}/node_modules" ]]; then
244+
# If BAZEL_NODE_MODULES_ROOT is set and it is not , add it to the list of bazel patch guards
245+
# Also, add the external/${BAZEL_NODE_MODULES_ROOT} which is the correct path under execroot
246+
# and under runfiles it is the legacy external runfiles path
247+
export BAZEL_PATCH_GUARDS="${BAZEL_PATCH_GUARDS},${BAZEL_PATCH_ROOT}/${BAZEL_NODE_MODULES_ROOT},${PWD}/external/${BAZEL_NODE_MODULES_ROOT}"
248+
fi
249+
fi
250+
220251
# The EXPECTED_EXIT_CODE lets us write bazel tests which assert that
221252
# a binary fails to run. Otherwise any failure would make such a test
222253
# fail before we could assert that we expected that failure.

internal/node/node.bzl

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def _compute_node_modules_root(ctx):
7979
] if f])
8080
return node_modules_root
8181

82-
def _write_require_patch_script(ctx):
82+
def _write_require_patch_script(ctx, node_modules_root):
8383
# Generates the JavaScript snippet of module roots mappings, with each entry
8484
# in the form:
8585
# {module_name: /^mod_name\b/, module_root: 'path/to/mod_name'}
@@ -91,8 +91,6 @@ def _write_require_patch_script(ctx):
9191
mapping = "{module_name: /^%s\\b/, module_root: '%s'}" % (escaped, mr)
9292
module_mappings.append(mapping)
9393

94-
node_modules_root = _compute_node_modules_root(ctx)
95-
9694
ctx.actions.expand_template(
9795
template = ctx.file._require_patch_template,
9896
output = ctx.outputs.require_patch_script,
@@ -175,7 +173,9 @@ def _nodejs_binary_impl(ctx):
175173
sources_depsets.append(d.files)
176174
sources = depset(transitive = sources_depsets)
177175

178-
_write_require_patch_script(ctx)
176+
node_modules_root = _compute_node_modules_root(ctx)
177+
178+
_write_require_patch_script(ctx, node_modules_root)
179179
_write_loader_script(ctx)
180180

181181
# Provide the target name as an environment variable avaiable to all actions for the
@@ -190,6 +190,13 @@ def _nodejs_binary_impl(ctx):
190190
# runfiles helpers to use.
191191
env_vars += "export BAZEL_WORKSPACE=%s\n" % ctx.workspace_name
192192

193+
# if BAZEL_NODE_MODULES_ROOT has not already been set by
194+
# run_node, then set it to the computed value
195+
env_vars += """if [[ -z "${BAZEL_NODE_MODULES_ROOT:-}" ]]; then
196+
export BAZEL_NODE_MODULES_ROOT=%s
197+
fi
198+
""" % node_modules_root
199+
193200
for k in ctx.attr.configuration_env_vars + ctx.attr.default_env_vars:
194201
# Check ctx.var first & if env var not in there then check
195202
# ctx.configuration.default_shell_env. The former will contain values from --define=FOO=BAR

internal/node/node_patches.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
6161
// es modules
6262

6363
// tslint:disable-next-line:no-any
64-
exports.patcher = (fs = fs$1, root) => {
64+
exports.patcher = (fs = fs$1, root, guards) => {
6565
fs = fs || fs$1;
6666
root = root || '';
67+
guards = guards || [];
6768
if (!root) {
6869
if (process.env.VERBOSE_LOGS) {
6970
console.error('fs patcher called without root path ' + __filename);
@@ -83,7 +84,7 @@ exports.patcher = (fs = fs$1, root) => {
8384
const origReadlinkSync = fs.readlinkSync.bind(fs);
8485
const origReaddir = fs.readdir.bind(fs);
8586
const origReaddirSync = fs.readdirSync.bind(fs);
86-
const { isEscape, isOutPath } = exports.escapeFunction(root);
87+
const { isEscape, isOutPath } = exports.escapeFunction(root, guards);
8788
// tslint:disable-next-line:no-any
8889
fs.lstat = (...args) => {
8990
let cb = args.length > 1 ? args[args.length - 1] : undefined;
@@ -483,27 +484,40 @@ exports.patcher = (fs = fs$1, root) => {
483484
}
484485
}
485486
};
486-
exports.escapeFunction = (root) => {
487-
// ensure root is always absolute.
487+
exports.escapeFunction = (root, guards) => {
488+
// ensure root & guards are always absolute.
488489
root = path.resolve(root);
490+
guards = guards.map(g => path.resolve(g));
489491
function isEscape(linkTarget, linkPath) {
490492
if (!path.isAbsolute(linkPath)) {
491493
linkPath = path.resolve(linkPath);
492494
}
493495
if (!path.isAbsolute(linkTarget)) {
494496
linkTarget = path.resolve(linkTarget);
495497
}
498+
if (isGuardPath(linkPath) || isGuardPath(linkTarget)) {
499+
// don't escape out of guard paths and don't symlink into guard paths
500+
return true;
501+
}
496502
if (root) {
497503
if (isOutPath(linkTarget) && !isOutPath(linkPath)) {
504+
// don't escape out of the root
498505
return true;
499506
}
500507
}
501508
return false;
502509
}
510+
function isGuardPath(str) {
511+
for (const g of guards) {
512+
if (str === g || str.startsWith(g + path.sep))
513+
return true;
514+
}
515+
return false;
516+
}
503517
function isOutPath(str) {
504518
return !root || (!str.startsWith(root + path.sep) && str !== root);
505519
}
506-
return { isEscape, isOutPath };
520+
return { isEscape, isGuardPath, isOutPath };
507521
};
508522
function once(fn) {
509523
let called = false;
@@ -644,12 +658,13 @@ var src_2 = src.subprocess;
644658
*/
645659

646660
// todo auto detect bazel env vars instead of adding a new one.
647-
const { BAZEL_PATCH_ROOT, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS } = process.env;
661+
const { BAZEL_PATCH_ROOT, BAZEL_PATCH_GUARDS, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS } = process.env;
648662
if (BAZEL_PATCH_ROOT) {
663+
const guards = BAZEL_PATCH_GUARDS ? BAZEL_PATCH_GUARDS.split(',') : [];
649664
if (VERBOSE_LOGS)
650665
console.error(`bazel node patches enabled. root: ${BAZEL_PATCH_ROOT} symlinks in this directory will not escape`);
651666
const fs = fs$1;
652-
src.fs(fs, BAZEL_PATCH_ROOT);
667+
src.fs(fs, BAZEL_PATCH_ROOT, guards);
653668
}
654669
else if (VERBOSE_LOGS) {
655670
console.error(`bazel node patches disabled. set environment BAZEL_PATCH_ROOT`);

internal/node/test/BUILD.bazel

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,21 @@ nodejs_binary(
7070
entry_point = ":module-name.js",
7171
)
7272

73+
jasmine_node_test(
74+
name = "env_test",
75+
srcs = [":env.spec.js"],
76+
data = [
77+
":dump_build_env.json",
78+
":dump_build_env_alt.json",
79+
],
80+
)
81+
7382
nodejs_test(
7483
name = "define_var",
7584
configuration_env_vars = [
7685
"SOME_TEST_ENV",
7786
"SOME_OTHER_ENV",
7887
],
79-
data = glob(["*.spec.js"]),
8088
entry_point = ":define.spec.js",
8189
)
8290

@@ -312,6 +320,31 @@ jasmine_node_test(
312320
],
313321
)
314322

323+
nodejs_binary(
324+
name = "dump_build_env",
325+
entry_point = "dump_build_env.js",
326+
)
327+
328+
nodejs_binary(
329+
name = "dump_build_env_alt",
330+
data = ["@npm//tmp"],
331+
entry_point = "dump_build_env.js",
332+
)
333+
334+
npm_package_bin(
335+
name = "dump_build_env_json",
336+
outs = ["dump_build_env.json"],
337+
args = ["$@"],
338+
tool = ":dump_build_env",
339+
)
340+
341+
npm_package_bin(
342+
name = "dump_build_env_alt_json",
343+
outs = ["dump_build_env_alt.json"],
344+
args = ["$@"],
345+
tool = ":dump_build_env_alt",
346+
)
347+
315348
nodejs_binary(
316349
name = "test_runfiles_helper",
317350
data = [":test_runfiles_helper.golden"],
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const fs = require('fs');
2+
const args = process.argv.slice(2);
3+
fs.writeFileSync(args.shift(), JSON.stringify(process.env, null, 2), 'utf-8');

internal/node/test/env.spec.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']);
4+
const isWindows = process.platform === 'win32';
5+
const runfilesExt = isWindows ? 'bat' : 'sh';
6+
7+
function normPath(p) {
8+
let result = p.replace(/\\/g, '/');
9+
if (isWindows) {
10+
// On Windows, we normalize to lowercase for so path mismatches such as 'C:/Users' and
11+
// 'c:/users' don't break the specs.
12+
result = result.toLowerCase();
13+
if (/[a-zA-Z]\:/.test(result)) {
14+
// Handle c:/ and /c/ mismatch
15+
result = `/${result[0]}${result.slice(2)}`;
16+
}
17+
}
18+
return result;
19+
}
20+
21+
function expectPathsToMatch(a, b) {
22+
if (Array.isArray(a) && Array.isArray(b)) {
23+
a = a.map(p => normPath(p));
24+
b = b.map(p => normPath(p));
25+
expect(a).toEqual(b);
26+
} else {
27+
expect(normPath(a)).toBe(normPath(b));
28+
}
29+
}
30+
31+
describe('launcher.sh environment', function() {
32+
it('should setup correct bazel environment variables when in runfiles', function() {
33+
const runfilesRoot = normPath(process.env['RUNFILES']);
34+
const match = runfilesRoot.match(/\/bazel-out\//);
35+
expect(!!match).toBe(true);
36+
const execroot = runfilesRoot.slice(0, match.index);
37+
expectPathsToMatch(path.basename(runfilesRoot), `env_test.${runfilesExt}.runfiles`);
38+
expectPathsToMatch(process.env['BAZEL_WORKSPACE'], 'build_bazel_rules_nodejs');
39+
expectPathsToMatch(process.env['BAZEL_TARGET'], '//internal/node/test:env_test');
40+
expectPathsToMatch(process.cwd(), `${process.env['RUNFILES']}/build_bazel_rules_nodejs`);
41+
expectPathsToMatch(process.env['PWD'], `${process.env['RUNFILES']}/build_bazel_rules_nodejs`);
42+
expectPathsToMatch(process.env['BAZEL_PATCH_ROOT'], process.env['RUNFILES']);
43+
expectPathsToMatch(process.env['BAZEL_NODE_MODULES_ROOT'], 'npm/node_modules');
44+
const expectedGuards = [
45+
`${execroot}/node_modules`,
46+
`${runfilesRoot}/npm/node_modules`,
47+
`${runfilesRoot}/build_bazel_rules_nodejs/external/npm/node_modules`,
48+
]
49+
expectPathsToMatch(process.env['BAZEL_PATCH_GUARDS'].split(','), expectedGuards);
50+
});
51+
52+
it('should setup correct bazel environment variables when in execroot with no third party deps',
53+
function() {
54+
const env = require(runfiles.resolvePackageRelative('dump_build_env.json'));
55+
// On Windows, the RUNFILES path ends in a /MANIFEST segment in this context
56+
const runfilesRoot = normPath(isWindows ? path.dirname(env['RUNFILES']) : env['RUNFILES']);
57+
const match = runfilesRoot.match(/\/bazel-out\//);
58+
expect(!!match).toBe(true);
59+
const execroot = runfilesRoot.slice(0, match.index);
60+
expectPathsToMatch(path.basename(runfilesRoot), `dump_build_env.${runfilesExt}.runfiles`);
61+
expectPathsToMatch(env['BAZEL_WORKSPACE'], 'build_bazel_rules_nodejs');
62+
expectPathsToMatch(env['BAZEL_TARGET'], '//internal/node/test:dump_build_env');
63+
expectPathsToMatch(env['PWD'], execroot);
64+
expectPathsToMatch(env['BAZEL_PATCH_ROOT'], path.dirname(execroot));
65+
expectPathsToMatch(env['BAZEL_NODE_MODULES_ROOT'], 'build_bazel_rules_nodejs/node_modules');
66+
const expectedGuards = [
67+
`${execroot}/node_modules`,
68+
]
69+
expectPathsToMatch(env['BAZEL_PATCH_GUARDS'].split(','), expectedGuards);
70+
});
71+
72+
it('should setup correct bazel environment variables when in execroot with third party deps',
73+
function() {
74+
const env = require(runfiles.resolvePackageRelative('dump_build_env_alt.json'));
75+
// On Windows, the RUNFILES path ends in a /MANIFEST segment in this context
76+
const runfilesRoot = normPath(isWindows ? path.dirname(env['RUNFILES']) : env['RUNFILES']);
77+
const match = runfilesRoot.match(/\/bazel-out\//);
78+
expect(!!match).toBe(true);
79+
const execroot = runfilesRoot.slice(0, match.index);
80+
expectPathsToMatch(
81+
path.basename(runfilesRoot), `dump_build_env_alt.${runfilesExt}.runfiles`);
82+
expectPathsToMatch(env['BAZEL_WORKSPACE'], 'build_bazel_rules_nodejs');
83+
expectPathsToMatch(env['BAZEL_TARGET'], '//internal/node/test:dump_build_env_alt');
84+
expectPathsToMatch(env['PWD'], execroot);
85+
expectPathsToMatch(env['BAZEL_PATCH_ROOT'], path.dirname(execroot));
86+
expectPathsToMatch(env['BAZEL_NODE_MODULES_ROOT'], 'npm/node_modules');
87+
const expectedGuards = [
88+
`${execroot}/node_modules`,
89+
`${path.dirname(execroot)}/npm/node_modules`,
90+
`${execroot}/external/npm/node_modules`,
91+
]
92+
expectPathsToMatch(env['BAZEL_PATCH_GUARDS'].split(','), expectedGuards);
93+
});
94+
});

internal/providers/node_runtime_deps_info.bzl

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Custom provider that mimics the Runfiles, but doesn't incur the expense of creating the runfiles symlink tree"""
1616

1717
load("//internal/linker:link_node_modules.bzl", "add_arg", "write_node_modules_manifest")
18+
load("//internal/providers:npm_package_info.bzl", "NpmPackageInfo")
1819

1920
NodeRuntimeDepsInfo = provider(
2021
doc = """Stores runtime dependencies of a nodejs_binary or nodejs_test
@@ -38,6 +39,23 @@ do the same.
3839
},
3940
)
4041

42+
def _compute_node_modules_root(ctx):
43+
"""Computes the node_modules root (if any) from data & deps targets."""
44+
node_modules_root = ""
45+
deps = []
46+
if hasattr(ctx.attr, "data"):
47+
deps += ctx.attr.data
48+
if hasattr(ctx.attr, "deps"):
49+
deps += ctx.attr.deps
50+
for d in deps:
51+
if NpmPackageInfo in d:
52+
possible_root = "/".join([d[NpmPackageInfo].workspace, "node_modules"])
53+
if not node_modules_root:
54+
node_modules_root = possible_root
55+
elif node_modules_root != possible_root:
56+
fail("All npm dependencies need to come from a single workspace. Found '%s' and '%s'." % (node_modules_root, possible_root))
57+
return node_modules_root
58+
4159
def run_node(ctx, inputs, arguments, executable, **kwargs):
4260
"""Helper to replace ctx.actions.run
4361
This calls node programs with a node_modules directory in place"""
@@ -77,6 +95,7 @@ def run_node(ctx, inputs, arguments, executable, **kwargs):
7795
env[var] = ctx.var[var]
7896
elif var in ctx.configuration.default_shell_env.keys():
7997
env[var] = ctx.configuration.default_shell_env[var]
98+
env["BAZEL_NODE_MODULES_ROOT"] = _compute_node_modules_root(ctx)
8099

81100
ctx.actions.run(
82101
inputs = inputs + extra_inputs,

packages/node-patches/register.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919
*/
2020
const patcher = require('./src');
2121
// todo auto detect bazel env vars instead of adding a new one.
22-
const {BAZEL_PATCH_ROOT, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS} = process.env;
22+
const {BAZEL_PATCH_ROOT, BAZEL_PATCH_GUARDS, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS} = process.env;
2323

2424
if (BAZEL_PATCH_ROOT) {
25+
const guards = BAZEL_PATCH_GUARDS ? BAZEL_PATCH_GUARDS.split(',') : [];
2526
if (VERBOSE_LOGS)
2627
console.error(`bazel node patches enabled. root: ${
2728
BAZEL_PATCH_ROOT} symlinks in this directory will not escape`);
2829
const fs = require('fs');
29-
patcher.fs(fs, BAZEL_PATCH_ROOT);
30+
patcher.fs(fs, BAZEL_PATCH_ROOT, guards);
3031
} else if (VERBOSE_LOGS) {
3132
console.error(`bazel node patches disabled. set environment BAZEL_PATCH_ROOT`);
3233
}

0 commit comments

Comments
 (0)