Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(builtin): don't allow symlinks to escape or enter bazel managed node_module folders #1800

Merged
merged 1 commit into from
Apr 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/node/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ filegroup(
visibility = ["//:__pkg__"],
)

# To update node_patches.js run:
# bazel run //internal/node:checked_in_node_patches.accept
golden_file_test(
name = "checked_in_node_patches",
actual = "//packages/node-patches:bundle",
Expand Down
31 changes: 31 additions & 0 deletions internal/node/launcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,37 @@ fi
# Bazel always sets the PWD to execroot/my_wksp so we go up one directory.
export BAZEL_PATCH_ROOT=$(dirname $PWD)

# Set all bazel managed node_modules directories as guarded so no symlinks may
# escape and no symlinks may enter
if [[ "$PWD" == *"/bazel-out/"* ]]; then
# We in runfiles, find the execroot.
# Look for `bazel-out` which is used to determine the the path to `execroot/my_wksp`. This works in
# all cases including on rbe where the execroot is a path such as `/b/f/w`. For example, when in
# runfiles on rbe, bazel runs the process in a directory such as
# `/b/f/w/bazel-out/k8-fastbuild/bin/path/to/pkg/some_test.sh.runfiles/my_wksp`. From here we can
# determine the execroot `b/f/w` by finding the first instance of bazel-out.
readonly bazel_out="/bazel-out/"
readonly rest=${PWD#*$bazel_out}
readonly index=$(( ${#PWD} - ${#rest} - ${#bazel_out} ))
if [[ ${index} < 0 ]]; then
echo "No 'bazel-out' folder found in path '${PWD}'!"
exit 1
fi
readonly execroot=${PWD:0:${index}}
export BAZEL_PATCH_GUARDS="${execroot}/node_modules"
else
# We are in execroot, linker node_modules is in the PWD
export BAZEL_PATCH_GUARDS="${PWD}/node_modules"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this doesn't work if your node_modules is in a subdirectory, which we support - but I think that's what BAZEL_NODE_MODULES_ROOT is doing below? maybe this needs a comment that we do this in a catch-all case where we don't have a BAZEL_NODE_MODULES_ROOT - but then why does that happen?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BAZEL_NODE_MODULES_ROOT is the path to the external workspace such as npm/node_modules.
This path is actually the path to the linker symlink node_modules under execroot which should work regardless of where node_modules is.

Copy link
Collaborator Author

@gregmagolan gregmagolan Apr 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BAZEL_NODE_MODULES_ROOT is always set in templated_args above:

    env_vars += """if [[ -z "${BAZEL_NODE_MODULES_ROOT:-}" ]]; then
  export BAZEL_NODE_MODULES_ROOT=%s
fi
""" % node_modules_root

so below is just a sanity check

fi
if [[ -n "${BAZEL_NODE_MODULES_ROOT:-}" ]]; then
if [[ "${BAZEL_NODE_MODULES_ROOT}" != "${BAZEL_WORKSPACE}/node_modules" ]]; then
# If BAZEL_NODE_MODULES_ROOT is set and it is not , add it to the list of bazel patch guards
# Also, add the external/${BAZEL_NODE_MODULES_ROOT} which is the correct path under execroot
# and under runfiles it is the legacy external runfiles path
export BAZEL_PATCH_GUARDS="${BAZEL_PATCH_GUARDS},${BAZEL_PATCH_ROOT}/${BAZEL_NODE_MODULES_ROOT},${PWD}/external/${BAZEL_NODE_MODULES_ROOT}"
fi
fi

# The EXPECTED_EXIT_CODE lets us write bazel tests which assert that
# a binary fails to run. Otherwise any failure would make such a test
# fail before we could assert that we expected that failure.
Expand Down
15 changes: 11 additions & 4 deletions internal/node/node.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _compute_node_modules_root(ctx):
] if f])
return node_modules_root

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

node_modules_root = _compute_node_modules_root(ctx)

ctx.actions.expand_template(
template = ctx.file._require_patch_template,
output = ctx.outputs.require_patch_script,
Expand Down Expand Up @@ -175,7 +173,9 @@ def _nodejs_binary_impl(ctx):
sources_depsets.append(d.files)
sources = depset(transitive = sources_depsets)

_write_require_patch_script(ctx)
node_modules_root = _compute_node_modules_root(ctx)

_write_require_patch_script(ctx, node_modules_root)
_write_loader_script(ctx)

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

# if BAZEL_NODE_MODULES_ROOT has not already been set by
# run_node, then set it to the computed value
env_vars += """if [[ -z "${BAZEL_NODE_MODULES_ROOT:-}" ]]; then
export BAZEL_NODE_MODULES_ROOT=%s
fi
""" % node_modules_root

for k in ctx.attr.configuration_env_vars + ctx.attr.default_env_vars:
# Check ctx.var first & if env var not in there then check
# ctx.configuration.default_shell_env. The former will contain values from --define=FOO=BAR
Expand Down
29 changes: 22 additions & 7 deletions internal/node/node_patches.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
// es modules

// tslint:disable-next-line:no-any
exports.patcher = (fs = fs$1, root) => {
exports.patcher = (fs = fs$1, root, guards) => {
fs = fs || fs$1;
root = root || '';
guards = guards || [];
if (!root) {
if (process.env.VERBOSE_LOGS) {
console.error('fs patcher called without root path ' + __filename);
Expand All @@ -83,7 +84,7 @@ exports.patcher = (fs = fs$1, root) => {
const origReadlinkSync = fs.readlinkSync.bind(fs);
const origReaddir = fs.readdir.bind(fs);
const origReaddirSync = fs.readdirSync.bind(fs);
const { isEscape, isOutPath } = exports.escapeFunction(root);
const { isEscape, isOutPath } = exports.escapeFunction(root, guards);
// tslint:disable-next-line:no-any
fs.lstat = (...args) => {
let cb = args.length > 1 ? args[args.length - 1] : undefined;
Expand Down Expand Up @@ -483,27 +484,40 @@ exports.patcher = (fs = fs$1, root) => {
}
}
};
exports.escapeFunction = (root) => {
// ensure root is always absolute.
exports.escapeFunction = (root, guards) => {
// ensure root & guards are always absolute.
root = path.resolve(root);
guards = guards.map(g => path.resolve(g));
function isEscape(linkTarget, linkPath) {
if (!path.isAbsolute(linkPath)) {
linkPath = path.resolve(linkPath);
}
if (!path.isAbsolute(linkTarget)) {
linkTarget = path.resolve(linkTarget);
}
if (isGuardPath(linkPath) || isGuardPath(linkTarget)) {
// don't escape out of guard paths and don't symlink into guard paths
return true;
}
if (root) {
if (isOutPath(linkTarget) && !isOutPath(linkPath)) {
// don't escape out of the root
return true;
}
}
return false;
}
function isGuardPath(str) {
for (const g of guards) {
if (str === g || str.startsWith(g + path.sep))
return true;
}
return false;
}
function isOutPath(str) {
return !root || (!str.startsWith(root + path.sep) && str !== root);
}
return { isEscape, isOutPath };
return { isEscape, isGuardPath, isOutPath };
};
function once(fn) {
let called = false;
Expand Down Expand Up @@ -644,12 +658,13 @@ var src_2 = src.subprocess;
*/

// todo auto detect bazel env vars instead of adding a new one.
const { BAZEL_PATCH_ROOT, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS } = process.env;
const { BAZEL_PATCH_ROOT, BAZEL_PATCH_GUARDS, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS } = process.env;
if (BAZEL_PATCH_ROOT) {
const guards = BAZEL_PATCH_GUARDS ? BAZEL_PATCH_GUARDS.split(',') : [];
if (VERBOSE_LOGS)
console.error(`bazel node patches enabled. root: ${BAZEL_PATCH_ROOT} symlinks in this directory will not escape`);
const fs = fs$1;
src.fs(fs, BAZEL_PATCH_ROOT);
src.fs(fs, BAZEL_PATCH_ROOT, guards);
}
else if (VERBOSE_LOGS) {
console.error(`bazel node patches disabled. set environment BAZEL_PATCH_ROOT`);
Expand Down
35 changes: 34 additions & 1 deletion internal/node/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,21 @@ nodejs_binary(
entry_point = ":module-name.js",
)

jasmine_node_test(
name = "env_test",
srcs = [":env.spec.js"],
data = [
":dump_build_env.json",
":dump_build_env_alt.json",
],
)

nodejs_test(
name = "define_var",
configuration_env_vars = [
"SOME_TEST_ENV",
"SOME_OTHER_ENV",
],
data = glob(["*.spec.js"]),
entry_point = ":define.spec.js",
)

Expand Down Expand Up @@ -312,6 +320,31 @@ jasmine_node_test(
],
)

nodejs_binary(
name = "dump_build_env",
entry_point = "dump_build_env.js",
)

nodejs_binary(
name = "dump_build_env_alt",
data = ["@npm//tmp"],
entry_point = "dump_build_env.js",
)

npm_package_bin(
name = "dump_build_env_json",
outs = ["dump_build_env.json"],
args = ["$@"],
tool = ":dump_build_env",
)

npm_package_bin(
name = "dump_build_env_alt_json",
outs = ["dump_build_env_alt.json"],
args = ["$@"],
tool = ":dump_build_env_alt",
)

nodejs_binary(
name = "test_runfiles_helper",
data = [":test_runfiles_helper.golden"],
Expand Down
3 changes: 3 additions & 0 deletions internal/node/test/dump_build_env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const fs = require('fs');
const args = process.argv.slice(2);
fs.writeFileSync(args.shift(), JSON.stringify(process.env, null, 2), 'utf-8');
94 changes: 94 additions & 0 deletions internal/node/test/env.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const fs = require('fs');
const path = require('path');
const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']);
const isWindows = process.platform === 'win32';
const runfilesExt = isWindows ? 'bat' : 'sh';

function normPath(p) {
let result = p.replace(/\\/g, '/');
if (isWindows) {
// On Windows, we normalize to lowercase for so path mismatches such as 'C:/Users' and
// 'c:/users' don't break the specs.
result = result.toLowerCase();
if (/[a-zA-Z]\:/.test(result)) {
// Handle c:/ and /c/ mismatch
result = `/${result[0]}${result.slice(2)}`;
}
}
return result;
}

function expectPathsToMatch(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
a = a.map(p => normPath(p));
b = b.map(p => normPath(p));
expect(a).toEqual(b);
} else {
expect(normPath(a)).toBe(normPath(b));
}
}

describe('launcher.sh environment', function() {
it('should setup correct bazel environment variables when in runfiles', function() {
const runfilesRoot = normPath(process.env['RUNFILES']);
const match = runfilesRoot.match(/\/bazel-out\//);
expect(!!match).toBe(true);
const execroot = runfilesRoot.slice(0, match.index);
expectPathsToMatch(path.basename(runfilesRoot), `env_test.${runfilesExt}.runfiles`);
expectPathsToMatch(process.env['BAZEL_WORKSPACE'], 'build_bazel_rules_nodejs');
expectPathsToMatch(process.env['BAZEL_TARGET'], '//internal/node/test:env_test');
expectPathsToMatch(process.cwd(), `${process.env['RUNFILES']}/build_bazel_rules_nodejs`);
expectPathsToMatch(process.env['PWD'], `${process.env['RUNFILES']}/build_bazel_rules_nodejs`);
expectPathsToMatch(process.env['BAZEL_PATCH_ROOT'], process.env['RUNFILES']);
expectPathsToMatch(process.env['BAZEL_NODE_MODULES_ROOT'], 'npm/node_modules');
const expectedGuards = [
`${execroot}/node_modules`,
`${runfilesRoot}/npm/node_modules`,
`${runfilesRoot}/build_bazel_rules_nodejs/external/npm/node_modules`,
]
expectPathsToMatch(process.env['BAZEL_PATCH_GUARDS'].split(','), expectedGuards);
});

it('should setup correct bazel environment variables when in execroot with no third party deps',
function() {
const env = require(runfiles.resolvePackageRelative('dump_build_env.json'));
// On Windows, the RUNFILES path ends in a /MANIFEST segment in this context
const runfilesRoot = normPath(isWindows ? path.dirname(env['RUNFILES']) : env['RUNFILES']);
const match = runfilesRoot.match(/\/bazel-out\//);
expect(!!match).toBe(true);
const execroot = runfilesRoot.slice(0, match.index);
expectPathsToMatch(path.basename(runfilesRoot), `dump_build_env.${runfilesExt}.runfiles`);
expectPathsToMatch(env['BAZEL_WORKSPACE'], 'build_bazel_rules_nodejs');
expectPathsToMatch(env['BAZEL_TARGET'], '//internal/node/test:dump_build_env');
expectPathsToMatch(env['PWD'], execroot);
expectPathsToMatch(env['BAZEL_PATCH_ROOT'], path.dirname(execroot));
expectPathsToMatch(env['BAZEL_NODE_MODULES_ROOT'], 'build_bazel_rules_nodejs/node_modules');
const expectedGuards = [
`${execroot}/node_modules`,
]
expectPathsToMatch(env['BAZEL_PATCH_GUARDS'].split(','), expectedGuards);
});

it('should setup correct bazel environment variables when in execroot with third party deps',
function() {
const env = require(runfiles.resolvePackageRelative('dump_build_env_alt.json'));
// On Windows, the RUNFILES path ends in a /MANIFEST segment in this context
const runfilesRoot = normPath(isWindows ? path.dirname(env['RUNFILES']) : env['RUNFILES']);
const match = runfilesRoot.match(/\/bazel-out\//);
expect(!!match).toBe(true);
const execroot = runfilesRoot.slice(0, match.index);
expectPathsToMatch(
path.basename(runfilesRoot), `dump_build_env_alt.${runfilesExt}.runfiles`);
expectPathsToMatch(env['BAZEL_WORKSPACE'], 'build_bazel_rules_nodejs');
expectPathsToMatch(env['BAZEL_TARGET'], '//internal/node/test:dump_build_env_alt');
expectPathsToMatch(env['PWD'], execroot);
expectPathsToMatch(env['BAZEL_PATCH_ROOT'], path.dirname(execroot));
expectPathsToMatch(env['BAZEL_NODE_MODULES_ROOT'], 'npm/node_modules');
const expectedGuards = [
`${execroot}/node_modules`,
`${path.dirname(execroot)}/npm/node_modules`,
`${execroot}/external/npm/node_modules`,
]
expectPathsToMatch(env['BAZEL_PATCH_GUARDS'].split(','), expectedGuards);
});
});
19 changes: 19 additions & 0 deletions internal/providers/node_runtime_deps_info.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""Custom provider that mimics the Runfiles, but doesn't incur the expense of creating the runfiles symlink tree"""

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

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

def _compute_node_modules_root(ctx):
"""Computes the node_modules root (if any) from data & deps targets."""
node_modules_root = ""
deps = []
if hasattr(ctx.attr, "data"):
deps += ctx.attr.data
if hasattr(ctx.attr, "deps"):
deps += ctx.attr.deps
for d in deps:
if NpmPackageInfo in d:
possible_root = "/".join([d[NpmPackageInfo].workspace, "node_modules"])
if not node_modules_root:
node_modules_root = possible_root
elif node_modules_root != possible_root:
fail("All npm dependencies need to come from a single workspace. Found '%s' and '%s'." % (node_modules_root, possible_root))
return node_modules_root

def run_node(ctx, inputs, arguments, executable, **kwargs):
"""Helper to replace ctx.actions.run
This calls node programs with a node_modules directory in place"""
Expand Down Expand Up @@ -77,6 +95,7 @@ def run_node(ctx, inputs, arguments, executable, **kwargs):
env[var] = ctx.var[var]
elif var in ctx.configuration.default_shell_env.keys():
env[var] = ctx.configuration.default_shell_env[var]
env["BAZEL_NODE_MODULES_ROOT"] = _compute_node_modules_root(ctx)

ctx.actions.run(
inputs = inputs + extra_inputs,
Expand Down
5 changes: 3 additions & 2 deletions packages/node-patches/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
*/
const patcher = require('./src');
// todo auto detect bazel env vars instead of adding a new one.
const {BAZEL_PATCH_ROOT, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS} = process.env;
const {BAZEL_PATCH_ROOT, BAZEL_PATCH_GUARDS, NP_SUBPROCESS_BIN_DIR, VERBOSE_LOGS} = process.env;

if (BAZEL_PATCH_ROOT) {
const guards = BAZEL_PATCH_GUARDS ? BAZEL_PATCH_GUARDS.split(',') : [];
if (VERBOSE_LOGS)
console.error(`bazel node patches enabled. root: ${
BAZEL_PATCH_ROOT} symlinks in this directory will not escape`);
const fs = require('fs');
patcher.fs(fs, BAZEL_PATCH_ROOT);
patcher.fs(fs, BAZEL_PATCH_ROOT, guards);
} else if (VERBOSE_LOGS) {
console.error(`bazel node patches disabled. set environment BAZEL_PATCH_ROOT`);
}
Expand Down
Loading