Repository: alibaba/OpenSandbox

Issue:
SECURITY: symbolic link within a whitelisted path that points to the host root directory `/`, and then request a mount using the symlink path.
The `allowed_host_paths` configuration option in OpenSandbox is intended to restrict sandbox containers to a whitelist of host paths that may be mounted. However, when validating paths, `_validate_host_volume` only uses `os.path.normpath` for lexical normalization and never calls `os.path.realpath` to resolve symbolic links.

An attacker can create a symbolic link within a whitelisted path that points to the host root directory `/`, and then request a mount using the symlink path. The lexical validation passes because the path begins with the whitelisted prefix, but Docker resolves the symbolic link when performing the bind mount, so the actual mounted path can be any host path chosen by the attacker.

This vulnerability remains exploitable even when the administrator has correctly configured the `allowed_host_paths` whitelist, rendering the security mechanism completely ineffective.

# FIXES
1. Add symbolic link detection in `ensure_valid_host_path`: check whether each intermediate path component is a symbolic link, and reject any path that contains symlinks.

2. Align the security validation logic for PVC volumes: `_validate_pvc_volume` already uses `os.path.realpath(strict=True)`, and host volumes should enforce the same standard.

Discussion:

Fix (patch):
diff --git a/server/opensandbox_server/services/docker.py b/server/opensandbox_server/services/docker.py
index 66d442299..c251fcf48 100644
--- a/server/opensandbox_server/services/docker.py
+++ b/server/opensandbox_server/services/docker.py
@@ -1466,8 +1466,9 @@ def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None
         Docker-specific validation for host bind mount volumes.
 
         Validates that the resolved host path (host.path + optional subPath)
-        remains within allowed prefixes, then ensures the directory exists on
-        the filesystem — creating it automatically if it does not.
+        remains within allowed prefixes — including symlink resolution — then
+        ensures the directory exists on the filesystem, creating it automatically
+        if it does not.
 
         Args:
             volume: Volume with host backend.
@@ -1487,6 +1488,19 @@ def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None
         if allowed_prefixes and resolved_path != volume.host.path:
             ensure_valid_host_path(resolved_path, allowed_prefixes)
 
+        # ── Symlink-aware allowlist check ──
+        # os.path.normpath and ensure_valid_host_path only perform lexical
+        # checks.  A symlink within a whitelisted directory (e.g.
+        # /data/opensandbox/link -> /) would pass lexical validation but
+        # Docker resolves the symlink when creating the bind mount, escaping
+        # the allowed prefix.  Resolve both the host path and the allowed
+        # prefixes with realpath to detect this.
+        if allowed_prefixes:
+            canonical = os.path.realpath(resolved_path)
+            canonical_prefixes = [os.path.realpath(p) for p in allowed_prefixes]
+            if canonical != resolved_path or canonical_prefixes != allowed_prefixes:
+                ensure_valid_host_path(canonical, canonical_prefixes)
+
         # Allow existing host files (for example ISO binds to /boot.iso)
         # without attempting directory creation.
         if os.path.isfile(resolved_path):


Test changes:
diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py
index a2bf33175..3e35a29d2 100644
--- a/server/tests/test_docker_service.py
+++ b/server/tests/test_docker_service.py
@@ -3189,6 +3189,56 @@ async def test_host_volume_with_subpath_resolved_correctly(self, mock_docker):
             assert len(binds) == 1
             assert binds[0] == f"{sub_dir}:/mnt/work:ro"
 
+    @pytest.mark.asyncio
+    async def test_host_volume_symlink_bypass_rejected(self, mock_docker):
+        """Host volume with symlink escaping allowed prefix should be rejected."""
+        mock_client = MagicMock()
+        mock_client.containers.list.return_value = []
+        mock_client.api.create_host_config.return_value = {}
+        mock_client.api.create_container.return_value = {"Id": "cid"}
+        mock_client.containers.get.return_value = MagicMock()
+        mock_docker.from_env.return_value = mock_client
+
+        import tempfile
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a symlink within the allowed path that points to /
+            link_path = os.path.join(tmpdir, "escape")
+            os.symlink("/", link_path)
+
+            cfg = _app_config()
+            cfg.storage = StorageConfig(allowed_host_paths=[tmpdir])
+            service = DockerSandboxService(config=cfg)
+
+            # Request /tmpdir/escape/etc — lexical check passes (starts with
+            # tmpdir) but realpath resolves escape -> /, producing /etc which
+            # is outside the allowed prefix.
+            request = CreateSandboxRequest(
+                image=ImageSpec(uri="python:3.11"),
+                timeout=120,
+                resourceLimits=ResourceLimits(root={}),
+                env={},
+                metadata={},
+                entrypoint=["python"],
+                volumes=[
+                    Volume(
+                        name="escape-vol",
+                        host=Host(path=os.path.join(link_path, "etc")),
+                        mount_path="/mnt/etc",
+                        read_only=True,
+                    )
+                ],
+            )
+
+            with (
+                patch.object(service, "_ensure_image_available"),
+                patch.object(service, "_prepare_sandbox_runtime"),
+            ):
+                with pytest.raises(HTTPException) as exc_info:
+                    await service.create_sandbox(request)
+            assert exc_info.value.status_code == 400
+            assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED
+
     @pytest.mark.asyncio
     async def test_host_subpath_auto_created(self, mock_docker):
         """Host volume with non-existent subPath should be auto-created."""