Skip to content
Open
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
1 change: 1 addition & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ bazel_dep(name = "bazel_lib", version = "3.0.0")
bazel_dep(name = "bazel_features", version = "1.41.0")
bazel_dep(name = "bazel_skylib", version = "1.5.0")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "rules_cc", version = "0.1.0")
bazel_dep(name = "rules_nodejs", version = "6.7.3")

# Ensure any version of aspect_bazel_lib used includes:
Expand Down
1 change: 1 addition & 0 deletions e2e/esm_sandbox/.bazelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
2 changes: 2 additions & 0 deletions e2e/esm_sandbox/.bazelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import %workspace%/../../tools/preset.bazelrc
import %workspace%/../e2e.bazelrc
1 change: 1 addition & 0 deletions e2e/esm_sandbox/.bazelversion
7 changes: 7 additions & 0 deletions e2e/esm_sandbox/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
load("@aspect_rules_js//js:defs.bzl", "js_test")

js_test(
name = "esm_sandbox_test",
data = ["dep.mjs"],
entry_point = "entry.mjs",
)
7 changes: 7 additions & 0 deletions e2e/esm_sandbox/MODULE.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module(name = "e2e_esm_sandbox")

bazel_dep(name = "aspect_rules_js", version = "0.0.0", dev_dependency = True)
local_path_override(
module_name = "aspect_rules_js",
path = "../..",
)
4 changes: 4 additions & 0 deletions e2e/esm_sandbox/dep.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// dep.mjs -- Simple ESM dependency that exports its own import.meta.url
// Used by the ESM sandbox test to verify ESM imports resolve correctly.

export const depUrl = import.meta.url;
121 changes: 121 additions & 0 deletions e2e/esm_sandbox/entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// entry.mjs -- ESM sandbox integration test for issue #362
//
// Verifies that ESM imports resolve correctly within the Bazel sandbox
// and that fs.realpathSync.native() does not escape the sandbox.
//
// Issue #362: Node.js ESM resolver captures realpathSync.native() via
// destructuring BEFORE --require patches run, so resolved paths escape
// the Bazel sandbox. The native FS sandbox (LD_PRELOAD) fixes this by
// intercepting libc realpath() at the C level.

import { depUrl } from "./dep.mjs";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

let passed = true;

function check(description, condition) {
if (condition) {
console.log(` PASS: ${description}`);
} else {
console.log(` FAIL: ${description}`);
passed = false;
}
}

console.log("ESM sandbox test (issue #362):");

// The FS_PATCH_ROOTS env var contains the sandbox roots (colon-separated).
// Paths returned by realpathSync must stay within these roots.
// This env var MUST be set by the js_binary launcher — if it's missing,
// the native sandbox is not active and the test should fail.
const rootsEnv = process.env.JS_BINARY__FS_PATCH_ROOTS;
if (!rootsEnv) {
console.log(" FAIL: JS_BINARY__FS_PATCH_ROOTS is not set — native sandbox not active");
process.exit(1);
}
const roots = rootsEnv.split(":").filter(Boolean);
if (roots.length === 0) {
console.log(" FAIL: JS_BINARY__FS_PATCH_ROOTS is empty — no sandbox roots configured");
process.exit(1);
}
console.log(` configured roots: ${roots.length}`);

function isWithinRoots(p) {
return roots.some(root => p.startsWith(root));
}

// Get file paths from import.meta.url
const entryPath = fileURLToPath(import.meta.url);
const depPath = fileURLToPath(depUrl);
const entryDir = dirname(entryPath);

console.log(` entry path: ${entryPath}`);
console.log(` dep path: ${depPath}`);

// Verify import.meta.url is a file:// URL
check(
"entry import.meta.url is file:// URL",
import.meta.url.startsWith("file://")
);
check(
"dep import.meta.url is file:// URL",
depUrl.startsWith("file://")
);

// Verify dep.mjs is in the same directory as entry.mjs
check(
"dep.mjs is in same directory as entry.mjs",
depPath.startsWith(entryDir)
);

// ---- CORE TEST for issue #362 ----
// realpathSync.native() is what the ESM resolver uses internally.
// Without the native FS sandbox, this would resolve through symlinks
// to the real execroot OUTSIDE the sandbox. With our fix, it should
// return a path that stays within the configured roots.
try {
const realNative = realpathSync.native(entryPath);
console.log(` realpathSync.native: ${realNative}`);

check(
"realpathSync.native() returns a valid path",
typeof realNative === "string" && realNative.length > 0
);

check(
"realpathSync.native() stays within sandbox roots",
isWithinRoots(realNative)
);
} catch (err) {
console.log(` FAIL: realpathSync.native() threw: ${err.message}`);
passed = false;
}

// Also verify the JS-level realpathSync (patched by --require)
try {
const realJS = realpathSync(entryPath);
console.log(` realpathSync: ${realJS}`);

check(
"realpathSync() returns a valid path",
typeof realJS === "string" && realJS.length > 0
);

check(
"realpathSync() stays within sandbox roots",
isWithinRoots(realJS)
);
} catch (err) {
console.log(` FAIL: realpathSync() threw: ${err.message}`);
passed = false;
}

if (passed) {
console.log("PASS: All ESM sandbox checks passed.");
process.exit(0);
} else {
console.log("FAIL: Some ESM sandbox checks failed.");
process.exit(1);
}
99 changes: 99 additions & 0 deletions js/private/fs_patches_native/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library")

package(default_visibility = ["//visibility:private"])

cc_library(
name = "fs_patch_common",
srcs = [
"fs_patch_common.c",
"fs_patch_init.c",
],
hdrs = ["fs_patch.h"],
copts = [
"-fPIC",
"-Wall",
"-Wextra",
"-std=c11",
],
linkopts = ["-ldl"],
visibility = [
"//js/private:__pkg__",
"//js/private/fs_patches_native/test:__pkg__",
],
)

cc_binary(
name = "fs_patch_linux.so",
srcs = ["fs_patch_linux.c"],
copts = [
"-fPIC",
"-Wall",
"-Wextra",
"-std=c11",
],
linkopts = ["-ldl"],
linkshared = True,
target_compatible_with = ["@platforms//os:linux"],
visibility = ["//visibility:public"],
deps = [":fs_patch_common"],
)

cc_binary(
name = "fs_patch_macos.dylib",
srcs = ["fs_patch_macos.c"],
copts = [
"-fPIC",
"-Wall",
"-Wextra",
"-std=c11",
],
linkopts = ["-ldl"],
linkshared = True,
target_compatible_with = ["@platforms//os:macos"],
visibility = ["//visibility:public"],
deps = [":fs_patch_common"],
)

config_setting(
name = "_is_linux_x86_64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
)

config_setting(
name = "_is_macos_arm64",
constraint_values = [
"@platforms//os:macos",
"@platforms//cpu:arm64",
],
)

config_setting(
name = "_is_macos_x86_64",
constraint_values = [
"@platforms//os:macos",
"@platforms//cpu:x86_64",
],
)

# Noop placeholder for unsupported platforms.
# Native FS patching is supported on Linux x86_64 and macOS (arm64 + x86_64).
# ARM Linux cross-compilation requires a CC toolchain for the target platform.
genrule(
name = "_fs_patch_noop",
outs = ["fs_patch_noop"],
cmd = "touch $@",
)

alias(
name = "fs_patch_native",
actual = select({
":_is_linux_x86_64": ":fs_patch_linux.so",
":_is_macos_arm64": ":fs_patch_macos.dylib",
":_is_macos_x86_64": ":fs_patch_macos.dylib",
"//conditions:default": ":_fs_patch_noop",
}),
visibility = ["//visibility:public"],
)
118 changes: 118 additions & 0 deletions js/private/fs_patches_native/fs_patch.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* fs_patch.h — Native FS sandbox for rules_js
*
* Intercepts libc filesystem calls via LD_PRELOAD / DYLD_INSERT_LIBRARIES
* to prevent Node.js (and especially ESM imports) from escaping the Bazel
* sandbox / runfiles tree.
*
* See: https://github.com/aspect-build/rules_js/issues/362
*/
#ifndef FS_PATCH_H_
#define FS_PATCH_H_

#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <limits.h>
#include <stddef.h>
#include <stdio.h>

#ifndef PATH_MAX
#define PATH_MAX 4096
#endif

/* Maximum number of sandbox roots (execroot + runfiles + sandbox paths) */
#define FS_PATCH_MAX_ROOTS 16

/* Maximum symlink resolution depth to prevent ELOOP */
#define FS_PATCH_MAX_SYMLINK_DEPTH 256

/* --------------------------------------------------------------------------
* Configuration
* -------------------------------------------------------------------------- */

typedef struct {
char *roots[FS_PATCH_MAX_ROOTS];
int num_roots;
int enabled;
int debug;
} fs_patch_config_t;

extern fs_patch_config_t g_config;

/* --------------------------------------------------------------------------
* Core logic (fs_patch_common.c)
* -------------------------------------------------------------------------- */

/* Returns 1 if child is equal to parent or is under parent/ */
int is_sub_path(const char *parent, const char *child);

/* Returns root index (>=0) if link_path is in a root but target_path escapes it.
* Returns -1 if no escape detected. */
int check_escape(const char *link_path, const char *target_path);

/* Returns 1 if path is under any configured root */
int can_escape(const char *path);

/* Normalize path: resolve . and .. without following symlinks.
* Input must be absolute. Writes to buf (must be PATH_MAX). Returns buf or NULL. */
char *normalize_path(const char *path, char *buf);

/* Make path absolute: if relative, prepend cwd. Writes to buf. Returns buf or NULL. */
char *make_absolute(const char *path, char *buf);

/* Core guarded realpath: resolves path but stops at sandbox-escaping symlinks.
* If resolved_path is NULL, allocates result (caller must free).
* Returns resolved path or NULL on error (sets errno). */
char *guarded_realpath(const char *path, char *resolved_path);

/* --------------------------------------------------------------------------
* Initialization (fs_patch_init.c)
* -------------------------------------------------------------------------- */

/* Called automatically via __attribute__((constructor)).
* Reads env vars, resolves original function pointers. */
void fs_patch_init(void);

/* --------------------------------------------------------------------------
* Original function pointer typedefs — T1 (realpath) only
* -------------------------------------------------------------------------- */

typedef char *(*orig_realpath_fn)(const char *restrict, char *restrict);
typedef int (*orig_lstat_fn)(const char *restrict, struct stat *restrict);
typedef ssize_t (*orig_readlink_fn)(const char *restrict, char *restrict, size_t);

#ifdef __linux__
typedef char *(*orig___realpath_chk_fn)(const char *, char *, size_t);
typedef char *(*orig_canonicalize_file_name_fn)(const char *);
#endif /* __linux__ */

/* --------------------------------------------------------------------------
* Original function pointer declarations
* -------------------------------------------------------------------------- */

extern orig_realpath_fn orig_realpath;
extern orig_lstat_fn orig_lstat;
extern orig_readlink_fn orig_readlink;

#ifdef __linux__
extern orig___realpath_chk_fn orig___realpath_chk;
extern orig_canonicalize_file_name_fn orig_canonicalize_file_name;
#endif /* __linux__ */

/* --------------------------------------------------------------------------
* Debug logging
* -------------------------------------------------------------------------- */

#define FS_PATCH_DEBUG(fmt, ...) \
do { \
if (g_config.debug) { \
fprintf(stderr, "DEBUG: fs_patch: " fmt "\n", ##__VA_ARGS__); \
} \
} while (0)

#endif /* FS_PATCH_H_ */
Loading
Loading