This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/storm.git


The following commit(s) were added to refs/heads/master by this push:
     new e33d74df9 Restrict OCI bind-mount sources to configured directories 
and check the launch command username (#9010)
e33d74df9 is described below

commit e33d74df98155bfc464de58f466d7c05b517b28c
Author: reiabreu <[email protected]>
AuthorDate: Mon Aug 24 12:35:10 2026 +0100

    Restrict OCI bind-mount sources to configured directories and check the 
launch command username (#9010)
---
 docs/OCI-support.md                                |  16 ++++
 storm-core/src/native/worker-launcher/impl/main.c  |  28 ++++--
 .../src/native/worker-launcher/impl/oci/oci.c      |   7 +-
 .../src/native/worker-launcher/impl/oci/oci.h      |   8 +-
 .../native/worker-launcher/impl/oci/oci_config.h   |   6 ++
 .../worker-launcher/impl/oci/oci_launch_cmd.c      | 100 ++++++++++++++++++++-
 .../worker-launcher/impl/oci/oci_launch_cmd.h      |  20 +++++
 .../worker-launcher/test/test-worker-launcher.c    |  91 +++++++++++++++++++
 8 files changed, 262 insertions(+), 14 deletions(-)

diff --git a/docs/OCI-support.md b/docs/OCI-support.md
index a44959ea1..57185b05d 100644
--- a/docs/OCI-support.md
+++ b/docs/OCI-support.md
@@ -315,6 +315,22 @@ Then you need to set up storm with the following configs:
 | `storm.oci.resources.localizer`   | The plugin to use for oci resources 
localization. |
 | `storm.oci.resources.local.dir` | The local directory for localized oci 
resources. |
 
+### Allowed OCI bind-mount sources
+
+Bind-mount sources for OCI/runc workers are restricted to a configured set of 
directories, set in the root-owned `worker-launcher.cfg`:
+
+| Setting | Description |
+|---------|-------------|
+| `worker.launcher.oci.allowed.mount.source.dirs` | Comma-separated list of 
directories from which OCI bind-mount sources may come. A mount source is 
permitted only if it (resolved with `realpath`) is one of these directories or 
a path underneath one of them. |
+
+If `worker.launcher.oci.allowed.mount.source.dirs` is not set, no bind mounts 
are permitted and OCI/runc workers will not start. When enabling the OCI/runc 
manager, set it to the directories the supervisor mounts — this includes the 
paths in `storm.oci.readonly.bindmounts` and `storm.oci.readwrite.bindmounts`, 
and the system paths the runtime needs (for example `/etc/resolv.conf`, 
`/etc/hostname`, `/etc/hosts`, the nscd directory, the Storm home directory, 
and the cgroup root).
+
+List only directories that the container (topology) user cannot write to. A 
source is resolved with `realpath` before it is matched, but runc resolves it 
again at mount time, so a directory that the topology user can write to — such 
as the supervisor local directory or the worker/artifacts/tmp roots — could 
have a symlink planted in it that points outside the allowed set. Point bind 
mounts at specific, non-writable paths (for example the exact files under 
`storm.oci.readonly.bindmounts`) [...]
+
+```
+worker.launcher.oci.allowed.mount.source.dirs=/etc/resolv.conf,/etc/hostname,/etc/hosts,/var/run/nscd,/usr/lib/storm,/sys/fs/cgroup
+```
+
 For example, 
 ```bash
 storm.resource.isolation.plugin: 
"org.apache.storm.container.oci.RuncLibContainerManager"
diff --git a/storm-core/src/native/worker-launcher/impl/main.c 
b/storm-core/src/native/worker-launcher/impl/main.c
index d99cec7d3..c4c96cc1a 100644
--- a/storm-core/src/native/worker-launcher/impl/main.c
+++ b/storm-core/src/native/worker-launcher/impl/main.c
@@ -302,15 +302,31 @@ int main(int argc, char **argv) {
       working_dir = argv[optind++];
       const char* command_file = argv[optind++];
       const char* worker_artifacts_dir = argv[optind];
-      exit_code = setup_dir_permissions(working_dir, 1, TRUE);
-      if (exit_code == 0) {
-        exit_code = setup_worker_tmp_permissions(working_dir);
+      // Parse the launch command file before setup_dir_permissions changes
+      // the ownership of the worker directory (which contains the command
+      // file) below.
+      oci_launch_cmd* olc = parse_oci_launch_cmd(command_file);
+      if (olc == NULL) {
+        exit_code = INVALID_CONFIG_FILE;
+      } else if (strcmp(olc->username, user_name) != 0) {
+        // The launch command file's username must match the user passed to
+        // the worker-launcher on the command line.
+        fprintf(ERRORFILE, "ERROR: OCI command file username %s does not match 
%s\n",
+                olc->username, user_name);
+        fflush(ERRORFILE);
+        exit_code = INVALID_USER_NAME;
+      } else {
+        exit_code = setup_dir_permissions(working_dir, 1, TRUE);
         if (exit_code == 0) {
-          //becomes root.
-          setuid(0);
-          exit_code = run_oci_container(command_file, worker_artifacts_dir);
+          exit_code = setup_worker_tmp_permissions(working_dir);
+          if (exit_code == 0) {
+            //becomes root.
+            setuid(0);
+            exit_code = run_oci_container(olc, worker_artifacts_dir);
+          }
         }
       }
+      free_oci_launch_cmd(olc);
     }
   } else if (strcasecmp("reap-oci-container", command) == 0) {
     if (argc != 5) {
diff --git a/storm-core/src/native/worker-launcher/impl/oci/oci.c 
b/storm-core/src/native/worker-launcher/impl/oci/oci.c
index b473c2ce0..8eca5d900 100644
--- a/storm-core/src/native/worker-launcher/impl/oci/oci.c
+++ b/storm-core/src/native/worker-launcher/impl/oci/oci.c
@@ -797,10 +797,9 @@ static void exec_runc(const char* container_id, const 
char* runc_config_path,
   exit(ERROR_OCI_RUN_FAILED);
 }
 
-int run_oci_container(const char* command_file, const char* 
worker_artifacts_dir) {
+int run_oci_container(oci_launch_cmd* olc, const char* worker_artifacts_dir) {
   int rc = 0;
   char* runc_config_path = NULL;
-  oci_launch_cmd* olc = NULL;
 
   oci_launch_cmd_ctx* ctx = setup_oci_launch_cmd_ctx();
   if (ctx == NULL) {
@@ -809,9 +808,8 @@ int run_oci_container(const char* command_file, const char* 
worker_artifacts_dir
     goto cleanup;
   }
 
-  olc = parse_oci_launch_cmd(command_file);
   if (olc == NULL) {
-    fputs("ERROR: parse_oci_launch_cmd Failed\n", ERRORFILE);
+    fputs("ERROR: no parsed OCI launch command in run_oci_container\n", 
ERRORFILE);
     rc = INVALID_CONFIG_FILE;
     goto cleanup;
   }
@@ -875,7 +873,6 @@ umount_and_cleanup:
 
 cleanup:
   free(runc_config_path);
-  free_oci_launch_cmd(olc);
   free_oci_launch_cmd_ctx(ctx);
   return rc;
 }
diff --git a/storm-core/src/native/worker-launcher/impl/oci/oci.h 
b/storm-core/src/native/worker-launcher/impl/oci/oci.h
index 4e4e39717..625313b33 100644
--- a/storm-core/src/native/worker-launcher/impl/oci/oci.h
+++ b/storm-core/src/native/worker-launcher/impl/oci/oci.h
@@ -20,10 +20,14 @@
 
 #include <stdbool.h>
 
+#include "oci_launch_cmd.h"
+
 /**
- * Run a container via OCI.
+ * Run a container via OCI from an already parsed launch command. The caller
+ * parses the command file with parse_oci_launch_cmd and owns the returned
+ * structure.
  */
-int run_oci_container(const char* command_file, const char* 
worker_artifacts_dir);
+int run_oci_container(oci_launch_cmd* olc, const char* worker_artifacts_dir);
 
 // NOTE: Update init_oci_overlay_desc and destroy_oci_overlay_desc
 //       when this is changed.
diff --git a/storm-core/src/native/worker-launcher/impl/oci/oci_config.h 
b/storm-core/src/native/worker-launcher/impl/oci/oci_config.h
index 5793380f9..c33cd9444 100644
--- a/storm-core/src/native/worker-launcher/impl/oci/oci_config.h
+++ b/storm-core/src/native/worker-launcher/impl/oci/oci_config.h
@@ -30,4 +30,10 @@
 #define OCI_RUNC_CONFIG_KEY OCI_CONFIG_PREFIX "runc"
 #define DEFAULT_OCI_RUNC   "/usr/bin/runc"
 
+// Configuration for the comma-separated list of directories that bind-mount
+// sources in an OCI launch command may come from. A mount source is allowed
+// if it is one of the listed paths or a path underneath one of them.
+// If this is not set, no bind mounts are allowed.
+#define OCI_ALLOWED_MOUNT_SRCS_CONFIG_KEY OCI_CONFIG_PREFIX 
"allowed.mount.source.dirs"
+
 #endif /* OCI_OCI_CONFIG_H */
diff --git a/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.c 
b/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.c
index 8cd69af2c..15eea76b8 100644
--- a/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.c
+++ b/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.c
@@ -27,7 +27,9 @@
 #include "utils/file-utils.h"
 #include "utils/string-utils.h"
 
+#include "configuration.h"
 #include "worker-launcher.h"
+#include "oci_config.h"
 #include "oci_launch_cmd.h"
 
 #define SQUASHFS_MEDIA_TYPE     "application/vnd.squashfs"
@@ -297,6 +299,100 @@ static bool is_valid_mount_options(const cJSON* mo) {
   return true;
 }
 
+/**
+ * Check whether a path contains a "." or ".." component.
+ */
+bool has_relative_path_component(const char* path) {
+  const char* p = path;
+  while (*p != '\0') {
+    while (*p == '/') {
+      ++p;
+    }
+    const char* start = p;
+    while (*p != '\0' && *p != '/') {
+      ++p;
+    }
+    size_t len = (size_t)(p - start);
+    if ((len == 1 && start[0] == '.')
+        || (len == 2 && start[0] == '.' && start[1] == '.')) {
+      return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * Check whether the mount source equals the allowed path or is underneath it.
+ */
+bool is_mount_source_under(const char* source, const char* allowed) {
+  size_t allowed_len = strlen(allowed);
+  while (allowed_len > 1 && allowed[allowed_len - 1] == '/') {
+    --allowed_len;
+  }
+  if (strncmp(source, allowed, allowed_len) != 0) {
+    return false;
+  }
+  return source[allowed_len] == '\0' || source[allowed_len] == '/';
+}
+
+/**
+ * Check a mount source against the directories configured in
+ * worker-launcher.cfg under OCI_ALLOWED_MOUNT_SRCS_CONFIG_KEY. The source
+ * must be an absolute path with no "." or ".." component and must be equal
+ * to or under one of the configured directories. If none are configured,
+ * the source is rejected.
+ *
+ * The source and the configured directories are resolved with realpath()
+ * before the containment check, so a symlink whose textual path is under an
+ * allowed directory but which points outside of it is rejected on its
+ * resolved target rather than on its spelling. A source that cannot be
+ * resolved (for example one that does not exist) is rejected. This narrows
+ * but does not fully close the window, since runc resolves the path again at
+ * mount time; only allow-list directories that the container user cannot
+ * write to.
+ */
+bool is_valid_mount_source(const char* source) {
+  if (source[0] != '/' || has_relative_path_component(source)) {
+    fprintf(ERRORFILE,
+        "ERROR: OCI config mount source is not a normalized absolute path: 
%s\n",
+        source);
+    return false;
+  }
+  char* resolved_source = realpath(source, NULL);
+  if (resolved_source == NULL) {
+    fprintf(ERRORFILE, "ERROR: Cannot resolve OCI config mount source %s: 
%s\n",
+        source, strerror(errno));
+    return false;
+  }
+  bool allowed = false;
+  char** allowed_dirs = get_values(OCI_ALLOWED_MOUNT_SRCS_CONFIG_KEY);
+  if (allowed_dirs != NULL) {
+    char** entry;
+    for (entry = allowed_dirs; *entry != NULL; ++entry) {
+      char* resolved_allowed = realpath(*entry, NULL);
+      if (resolved_allowed == NULL) {
+        // a configured directory that cannot be resolved cannot contain 
anything
+        continue;
+      }
+      if (is_mount_source_under(resolved_source, resolved_allowed)) {
+        allowed = true;
+      }
+      free(resolved_allowed);
+      if (allowed) {
+        break;
+      }
+    }
+    free_values(allowed_dirs);
+  }
+  if (!allowed) {
+    fprintf(ERRORFILE,
+        "ERROR: OCI config mount source %s is not under any directory in %s\n",
+        source, OCI_ALLOWED_MOUNT_SRCS_CONFIG_KEY);
+  }
+  free(resolved_source);
+  return allowed;
+}
+
 static bool is_valid_mount(const cJSON* mount) {
   if (!cJSON_IsObject(mount)) {
     fputs("ERROR: OCI config mount entry is not an object\n", ERRORFILE);
@@ -356,7 +452,9 @@ static bool is_valid_mount(const cJSON* mount) {
     return false;
   }
 
-  // TODO: Need to add mount source/dest whitelist checking here.
+  if (!is_valid_mount_source(source)) {
+    return false;
+  }
 
   return true;
 }
diff --git a/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.h 
b/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.h
index d63799598..d8831bb62 100644
--- a/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.h
+++ b/storm-core/src/native/worker-launcher/impl/oci/oci_launch_cmd.h
@@ -76,4 +76,24 @@ oci_launch_cmd* parse_oci_launch_cmd(const char* 
command_filename);
  */
 bool validate_container_id(const char* input);
 
+/**
+ * Return true if the path contains a "." or ".." component.
+ */
+bool has_relative_path_component(const char* path);
+
+/**
+ * Return true if source equals allowed or is a path underneath it. Trailing
+ * slashes on allowed are ignored, and matching is on whole path components,
+ * so "/data/storm" matches "/data/storm/x" but not "/data/storm-evil".
+ */
+bool is_mount_source_under(const char* source, const char* allowed);
+
+/**
+ * Return true if source is a normalized absolute path that is equal to or
+ * under one of the directories configured in
+ * worker.launcher.oci.allowed.mount.source.dirs. If no such directories are
+ * configured, every source is rejected.
+ */
+bool is_valid_mount_source(const char* source);
+
 #endif /* OCI_OCI_LAUNCH_CMD_H */
\ No newline at end of file
diff --git a/storm-core/src/native/worker-launcher/test/test-worker-launcher.c 
b/storm-core/src/native/worker-launcher/test/test-worker-launcher.c
index 48ad24d3b..2a5784540 100644
--- a/storm-core/src/native/worker-launcher/test/test-worker-launcher.c
+++ b/storm-core/src/native/worker-launcher/test/test-worker-launcher.c
@@ -185,6 +185,92 @@ void test_validate_container_id() {
   }
 }
 
+// Fail the test (with a message) unless the condition holds.
+#define EXPECT(cond, msg) do { if (!(cond)) { printf("FAIL: %s\n", (msg)); 
exit(1); } } while (0)
+
+// Path helpers used by is_valid_mount_source. No config needed.
+void test_mount_path_helpers() {
+  // has_relative_path_component: only real "." / ".." components count
+  EXPECT(!has_relative_path_component("/data/storm"), "clean path flagged as 
relative");
+  EXPECT(!has_relative_path_component("/data/storm/resolv.conf"), "clean 
nested path flagged as relative");
+  EXPECT(has_relative_path_component("/data/../etc"), "'..' component not 
detected");
+  EXPECT(has_relative_path_component("/data/./x"), "'.' component not 
detected");
+  EXPECT(has_relative_path_component("/.."), "leading '..' not detected");
+  EXPECT(has_relative_path_component("/a/b/.."), "trailing '..' not detected");
+  EXPECT(!has_relative_path_component("/data/..storm"), "'..storm' wrongly 
treated as '..'");
+  EXPECT(!has_relative_path_component("/data/storm."), "'storm.' wrongly 
treated as '.'");
+  EXPECT(!has_relative_path_component(""), "empty path flagged as relative");
+
+  // is_mount_source_under: whole-component containment
+  EXPECT(is_mount_source_under("/data/storm/x", "/data/storm"), "path under 
allowed dir rejected");
+  EXPECT(is_mount_source_under("/data/storm", "/data/storm"), "allowed dir 
itself rejected");
+  EXPECT(is_mount_source_under("/data/storm/a/b", "/data/storm"), "deep path 
under allowed dir rejected");
+  EXPECT(!is_mount_source_under("/data/storm-evil/x", "/data/storm"), "dir 
with shared name prefix accepted");
+  EXPECT(!is_mount_source_under("/data/stormx", "/data/storm"), "prefix 
without separator accepted");
+  EXPECT(is_mount_source_under("/data/storm/x", "/data/storm/"), "trailing 
slash in allowed dir not tolerated");
+  EXPECT(is_mount_source_under("/data/storm", "/data/storm/"), "trailing slash 
vs equal path not tolerated");
+  EXPECT(!is_mount_source_under("/data", "/data/storm"), "parent of allowed 
dir accepted");
+  EXPECT(!is_mount_source_under("/etc/passwd", "/data/storm"), "unrelated path 
accepted");
+}
+
+// is_valid_mount_source resolves paths with realpath and reads global config, 
so it needs a real
+// tree and is run via run_test_in_child to keep read_config from leaking into 
later tests. The
+// rejected cases print to stderr, which is expected.
+void test_mount_source_allowed_dirs() {
+  // A real tree, since is_valid_mount_source now resolves with realpath:
+  //   .../allowed            an allow-listed directory
+  //   .../allowed/real.conf  a real file under it
+  //   .../allowed/escape     a symlink under it that points outside it
+  //   .../allowed-evil       a sibling whose name shares the prefix
+  //   .../outside/secret     a file outside every allowed directory
+  const char* base      = TEST_ROOT "/mounts";
+  const char* allowed   = TEST_ROOT "/mounts/allowed";
+  const char* real_conf = TEST_ROOT "/mounts/allowed/real.conf";
+  const char* escape    = TEST_ROOT "/mounts/allowed/escape";
+  const char* evil      = TEST_ROOT "/mounts/allowed-evil";
+  const char* outside   = TEST_ROOT "/mounts/outside";
+  const char* secret    = TEST_ROOT "/mounts/outside/secret";
+
+  EXPECT(mkdir(base, 0755) == 0 || errno == EEXIST, "could not create mounts 
base");
+  EXPECT(mkdir(allowed, 0755) == 0 || errno == EEXIST, "could not create 
allowed dir");
+  EXPECT(mkdir(evil, 0755) == 0 || errno == EEXIST, "could not create sibling 
dir");
+  EXPECT(mkdir(outside, 0755) == 0 || errno == EEXIST, "could not create 
outside dir");
+  FILE* rc = fopen(real_conf, "w");
+  EXPECT(rc != NULL, "could not create real.conf");
+  fclose(rc);
+  FILE* sc = fopen(secret, "w");
+  EXPECT(sc != NULL, "could not create secret");
+  fclose(sc);
+  EXPECT(symlink(secret, escape) == 0 || errno == EEXIST, "could not create 
escaping symlink");
+
+  const char* cfg = TEST_ROOT "/mount-allowed.cfg";
+  FILE* f = fopen(cfg, "w");
+  EXPECT(f != NULL, "could not write mount-allowed.cfg");
+  fprintf(f, "min.user.id=%d\n", getuid());
+  fprintf(f, "worker.launcher.oci.allowed.mount.source.dirs=%s\n", allowed);
+  fclose(f);
+  read_config(cfg);
+
+  EXPECT(is_valid_mount_source(allowed), "allowed dir itself rejected");
+  EXPECT(is_valid_mount_source(real_conf), "real path under allowed dir 
rejected");
+  // the key case: a symlink under the allowed dir that resolves outside it 
must be rejected
+  EXPECT(!is_valid_mount_source(escape), "symlink resolving outside the 
allowed dir accepted");
+  EXPECT(!is_valid_mount_source(secret), "path outside all allowed dirs 
accepted");
+  EXPECT(!is_valid_mount_source(evil), "sibling dir with shared name prefix 
accepted");
+  EXPECT(!is_valid_mount_source(TEST_ROOT "/mounts/does-not-exist"), 
"unresolvable source accepted");
+  EXPECT(!is_valid_mount_source("relative/path"), "non-absolute path 
accepted");
+
+  // With no directories configured, every source is rejected.
+  const char* cfg_none = TEST_ROOT "/mount-none.cfg";
+  f = fopen(cfg_none, "w");
+  EXPECT(f != NULL, "could not write mount-none.cfg");
+  fprintf(f, "min.user.id=%d\n", getuid());
+  fclose(f);
+  read_config(cfg_none);
+
+  EXPECT(!is_valid_mount_source(allowed), "source accepted with no directories 
configured");
+}
+
 void test_check_configuration_permissions() {
   printf("\nTesting check_configuration_permissions\n");
   if (check_configuration_permissions("/etc/passwd") != 0) {
@@ -407,6 +493,11 @@ int main(int argc, char **argv) {
 
   // the tests that change user need to be run in a subshell, so that
   // when they change user they don't give up our privs
+  printf("\nTesting mount path helpers\n");
+  test_mount_path_helpers();
+
+  run_test_in_child("test_mount_source_allowed_dirs", 
test_mount_source_allowed_dirs);
+
   run_test_in_child("test_signal_container", test_signal_container);
   run_test_in_child("test_signal_container_group", 
test_signal_container_group);
   run_test_in_child("test_get_values_degenerate", test_get_values_degenerate);

Reply via email to