Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any, Dict, List, Optional

from jumpstarter_driver_opendal.driver import Opendal
from pydantic import validate_call
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor: pydantic is imported here but is not listed as a direct dependency in pyproject.toml -- it is only available transitively via jumpstarter. If the core package ever changes its pydantic dependency, this driver would break. Consider adding pydantic as an explicit dependency.

AI-generated, human reviewed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is consistent with other drivers in the repo (e.g. jumpstarter-driver-opendal, jumpstarter-driver-can, jumpstarter-driver-qemu all import pydantic transitively via jumpstarter). Pydantic is a core dependency of jumpstarter itself (used in the driver base class). Adding it as an explicit dep to every driver would be a repo-wide policy change -- better handled in a separate issue/PR if desired.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ack

from rtslib_fb import LUN, TPG, BlockStorageObject, FileIOStorageObject, NetworkPortal, RTSRoot, Target

from jumpstarter.driver import Driver, export
Expand Down Expand Up @@ -47,6 +48,7 @@ class ISCSI(Driver):
host: str = field(default="")
port: int = 3260
remove_created_on_close: bool = False # Keep disk images persistent by default
block_device_allowlist: List[str] = field(default_factory=list)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess this parameter would need to be documented in the README.md of the driver.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! I've added block_device_allowlist to both the config parameters table and the YAML example in the driver README (commit 9232932).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor: there is no validation that allowlist entries are absolute paths. A relative path here would silently never match anything (since os.path.realpath always returns absolute paths), so it fails-closed, but it would be a silent configuration error. A simple check in __post_init__ could catch this early and save operators debugging time.

AI-generated, human reviewed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Will add validation in __post_init__ to reject non-absolute paths in the allowlist with a clear ConfigurationError.


_rtsroot: Optional[RTSRoot] = field(init=False, default=None)
_target: Optional[Target] = field(init=False, default=None)
Expand Down Expand Up @@ -186,6 +188,7 @@ def _cleanup_orphan_storage_objects(self):
self.logger.debug(f"No orphan storage object cleanup performed: {e}")

@export
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: @validate_call on methods like clear_all_luns(self), start(self), stop(self), get_host(self), get_port(self), get_target_iqn(self), and list_luns(self) adds pydantic validation overhead but these methods take no user-supplied parameters. Consider whether the decorator is needed on the 7 zero-parameter methods, or if it should only be applied to add_lun, remove_lun, and decompress which actually accept user input.

AI-generated, human reviewed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This follows the established project pattern -- the opendal driver applies @validate_call to every @export method uniformly. Keeping consistency across drivers is more maintainable than selectively applying it. The overhead on zero-param methods is negligible (no pydantic model is constructed when there are no parameters to validate).

@validate_call
def clear_all_luns(self):
"""Remove all existing LUNs and their backstores, including any orphans under root_dir"""
if self._tpg is None:
Expand All @@ -197,6 +200,7 @@ def clear_all_luns(self):
self._cleanup_orphan_storage_objects()

@export
@validate_call
def start(self):
"""Start the iSCSI target server

Expand All @@ -212,6 +216,7 @@ def start(self):
raise ISCSIError(f"Failed to start iSCSI target server: {e}") from e

@export
@validate_call
def stop(self):
"""Stop the iSCSI target server

Expand All @@ -232,6 +237,7 @@ def stop(self):
raise ISCSIError(f"Failed to stop iSCSI target: {e}") from e

@export
@validate_call
def get_host(self) -> str:
"""Get the host address the server is bound to

Expand All @@ -241,6 +247,7 @@ def get_host(self) -> str:
return self.host

@export
@validate_call
def get_port(self) -> int:
"""Get the port number the server is listening on

Expand All @@ -250,6 +257,7 @@ def get_port(self) -> int:
return self.port

@export
@validate_call
def get_target_iqn(self) -> str:
"""Get the IQN of the target

Expand All @@ -272,7 +280,17 @@ def _get_full_path(self, file_path: str, is_block: bool) -> str:
if is_block:
if not os.path.isabs(file_path):
raise ISCSIError("For block devices, file_path must be an absolute path")
return file_path
resolved_path = os.path.realpath(file_path)
if not self.block_device_allowlist:
raise ISCSIError(
"block_device_allowlist is empty; configure allowed block device paths "
"to use is_block=True"
)
if resolved_path not in self.block_device_allowlist:
Comment on lines +291 to +297
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Allowlist entries are not resolved/canonicalized

The user-supplied file_path is resolved via os.path.realpath(), but the entries in block_device_allowlist are compared as-is. This means if an operator configures a symlink path in the allowlist (e.g., /dev/disk/by-id/my-disk), requests for that same path will resolve to the real device (e.g., /dev/sda) and fail the check.

The behavior is secure (fails-closed), but it will be confusing for operators who naturally use stable device naming paths like /dev/disk/by-id/ or /dev/disk/by-path/.

Consider either:

  • Resolving allowlist entries during __post_init__ with os.path.realpath(), or
  • Updating the README to document that only canonical (non-symlink) paths should be used and removing the /dev/disk/by-id/my-disk example

AI-generated, human reviewed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. I will resolve allowlist entries via os.path.realpath() in __post_init__ so operators can use stable symlink paths like /dev/disk/by-id/.... This also makes the README example correct as-is.

raise ISCSIError(
f"Block device path '{resolved_path}' is not in the configured allowlist"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: including the resolved path in the error message could reveal filesystem layout details (e.g., real device names behind symlinks) to callers in a multi-tenant context. Consider whether this level of detail is appropriate, or if a more generic message would be better.

AI-generated, human reviewed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This error is raised server-side on the exporter host. The operator configuring block_device_allowlist is the same trusted admin running the exporter -- they already know the device topology. The resolved path in the error message helps them debug allowlist mismatches (e.g. figuring out what a symlink resolved to). In the Jumpstarter architecture, untrusted client callers receive a generic gRPC error, not the raw Python exception message.

)
return resolved_path
else:
normalized_path = os.path.normpath(file_path)

Expand Down Expand Up @@ -310,6 +328,7 @@ def _check_no_symlinks_in_path(self, path: str) -> None:
path_to_check = os.path.dirname(path_to_check)

@export
@validate_call
def decompress(self, src_path: str, dst_path: str, algo: str) -> None:
"""Decompress a file under storage root into another path under storage root.

Expand Down Expand Up @@ -383,6 +402,7 @@ def _create_file_storage_object(self, name: str, full_path: str, size_mb: int) -
return FileIOStorageObject(name, full_path, size=size_bytes), size_mb

@export
@validate_call
def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool = False) -> str:
"""
Add a new LUN to the iSCSI target.
Expand Down Expand Up @@ -427,6 +447,7 @@ def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool =
raise ISCSIError(f"Failed to add LUN: {e}") from e

@export
@validate_call
def remove_lun(self, name: str):
"""Remove a LUN from the iSCSI target

Expand Down Expand Up @@ -455,6 +476,7 @@ def remove_lun(self, name: str):
raise ISCSIError(f"Failed to remove LUN: {e}") from e

@export
@validate_call
def list_luns(self) -> List[Dict[str, Any]]:
"""List all configured LUNs

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Tests for iSCSI driver block device allowlist and path confinement."""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Heads up: these tests won't be discovered by pytest when running via make pkg-test-jumpstarter-driver-iscsi. The pyproject.toml for this package sets testpaths = ["src"], but there is no src/ directory -- the test file lives under jumpstarter_driver_iscsi/. This means pytest will look in a nonexistent directory and find zero tests.

This is a pre-existing configuration issue (not introduced by this PR), but since this PR adds security-critical tests, it would be worth fixing testpaths to point to jumpstarter_driver_iscsi (or removing the directive entirely) so these tests actually run in CI.

AI-generated, human reviewed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good find. The testpaths = ["src"] in pyproject.toml is indeed incorrect for this package layout. Will fix it to testpaths = ["jumpstarter_driver_iscsi"] in this PR since the new tests depend on it.


import os
import sys

import pytest

if sys.platform != "linux":
pytest.skip("iSCSI driver requires Linux (libudev)", allow_module_level=True)

from jumpstarter_driver_iscsi.driver import ISCSI, ISCSIError


@pytest.fixture
def tmp_root(tmp_path):
return str(tmp_path)


def _make_driver(tmp_root, block_device_allowlist=None):
"""Create an ISCSI driver instance without initializing rtslib."""
driver = object.__new__(ISCSI)
driver.root_dir = tmp_root
driver.block_device_allowlist = block_device_allowlist or []
return driver


class TestGetFullPathBlock:
"""Tests for _get_full_path with is_block=True."""

def test_block_empty_allowlist_rejects(self, tmp_root):
driver = _make_driver(tmp_root, block_device_allowlist=[])
with pytest.raises(ISCSIError, match="block_device_allowlist is empty"):
driver._get_full_path("/dev/sda", is_block=True)

def test_block_not_in_allowlist_rejects(self, tmp_root):
driver = _make_driver(tmp_root, block_device_allowlist=["/dev/sdb"])
with pytest.raises(ISCSIError, match="not in the configured allowlist"):
driver._get_full_path("/dev/sda", is_block=True)

def test_block_in_allowlist_accepted(self, tmp_root):
driver = _make_driver(tmp_root, block_device_allowlist=["/dev/sda"])
result = driver._get_full_path("/dev/sda", is_block=True)
assert result == "/dev/sda"

def test_block_relative_path_rejected(self, tmp_root):
driver = _make_driver(tmp_root, block_device_allowlist=["/dev/sda"])
with pytest.raises(ISCSIError, match="must be an absolute path"):
driver._get_full_path("dev/sda", is_block=True)

def test_block_symlink_resolved(self, tmp_root):
"""Symlinks are resolved before checking the allowlist."""
real_dev = os.path.join(tmp_root, "real_device")
link_path = os.path.join(tmp_root, "link_device")
# Create a real file and symlink
with open(real_dev, "w") as f:
f.write("")
os.symlink(real_dev, link_path)

driver = _make_driver(tmp_root, block_device_allowlist=[real_dev])
result = driver._get_full_path(link_path, is_block=True)
assert result == real_dev

def test_block_symlink_not_in_allowlist(self, tmp_root):
"""Symlink target not in allowlist should be rejected."""
real_dev = os.path.join(tmp_root, "real_device")
link_path = os.path.join(tmp_root, "link_device")
with open(real_dev, "w") as f:
f.write("")
os.symlink(real_dev, link_path)

driver = _make_driver(tmp_root, block_device_allowlist=[link_path])
with pytest.raises(ISCSIError, match="not in the configured allowlist"):
driver._get_full_path(link_path, is_block=True)


class TestGetFullPathFile:
"""Tests for _get_full_path with is_block=False (unchanged behavior)."""

def test_file_relative_path_confined(self, tmp_root):
driver = _make_driver(tmp_root)
result = driver._get_full_path("subdir/test.img", is_block=False)
assert result.startswith(tmp_root)

def test_file_absolute_path_rejected(self, tmp_root):
driver = _make_driver(tmp_root)
with pytest.raises(ISCSIError, match="Invalid file path"):
driver._get_full_path("/etc/passwd", is_block=False)

def test_file_traversal_rejected(self, tmp_root):
driver = _make_driver(tmp_root)
with pytest.raises(ISCSIError, match="Invalid file path"):
driver._get_full_path("../../etc/passwd", is_block=False)
56 changes: 55 additions & 1 deletion python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading