Skip to content

fix(iscsi): block device allowlist confinement#432

Open
ambient-code[bot] wants to merge 4 commits intomainfrom
fix/352-iscsi-block-device-confinement
Open

fix(iscsi): block device allowlist confinement#432
ambient-code[bot] wants to merge 4 commits intomainfrom
fix/352-iscsi-block-device-confinement

Conversation

@ambient-code
Copy link
Copy Markdown
Contributor

@ambient-code ambient-code bot commented Apr 8, 2026

Summary

  • Add block_device_allowlist configuration parameter to ISCSI driver that must be explicitly populated to allow block device exposure
  • Validate block device paths against the allowlist using os.path.realpath() to resolve symlinks before checking
  • Reject all block device requests when the allowlist is empty (secure by default)
  • Add @validate_call decorators to all 10 @export methods for pydantic input type validation
  • Add unit tests covering allowlist enforcement, symlink resolution, and path confinement

Closes #352

Test plan

  • 9 unit tests pass covering block device allowlist (empty, not-in-list, accepted, relative path, symlink resolution, symlink-not-in-allowlist) and file path confinement (relative, absolute, traversal)
  • Lint passes (make lint-fix)
  • CI checks pass

🤖 Generated with Claude Code

…rbitrary device exposure

Block device paths passed via add_lun(is_block=True) were accepted without
confinement. Add a block_device_allowlist config param that must be explicitly
populated; resolved paths are checked against it. Also add @validate_call to
all @export methods for input type validation.

Closes #352

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 8, 2026

Deploy Preview for jumpstarter-docs ready!

Name Link
🔨 Latest commit 9232932
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/69d6b441a35a720008b898fc
😎 Deploy Preview https://deploy-preview-432--jumpstarter-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

The iSCSI driver depends on rtslib_fb which requires libudev, a
Linux-only library. Add a module-level pytest.skip to prevent import
errors on macOS CI runners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ambient-code
Copy link
Copy Markdown
Contributor Author

ambient-code bot commented Apr 8, 2026

CI Fix: macOS test failures

Root cause: The new driver_test.py imports jumpstarter_driver_iscsi.driver at module level, which transitively imports rtslib_fb -> pyudev -> libudev. Since libudev is a Linux-only library, this causes an ImportError on macOS CI runners during test collection.

Fix applied (commit cd19c840): Added a module-level pytest.skip() guard before the driver import:

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

This ensures the test module is cleanly skipped on non-Linux platforms rather than failing with an import error.

Other CI observations:

  • pytest-matrix (ubuntu-24.04, 3.13) passed successfully
  • The pytest gate job failed only because the macOS matrix jobs failed/were cancelled
  • The uv.lock diff in the PR appears to match main now (no effective diff)
  • No unaddressed review comments on the PR

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).

Add the new block_device_allowlist config parameter to the README
config table and YAML example, as requested in PR review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@raballew raballew self-requested a review April 13, 2026 17:11
@@ -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

Comment on lines +283 to +289
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:
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.

@@ -42,6 +42,9 @@ export:
root_dir: "/var/lib/iscsi"
target_name: "demo"
remove_created_on_close: false # Keep disk images persistent (default)
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.

This example shows /dev/disk/by-id/my-disk as an allowlist entry, but symlink paths like this won't work as expected. The driver resolves user-supplied paths via os.path.realpath() before checking against the allowlist, but allowlist entries themselves are not resolved. So a request for /dev/disk/by-id/my-disk would resolve to something like /dev/sda and then fail the allowlist check.

If the driver is updated to resolve allowlist entries, this example is fine. Otherwise, this should probably show a canonical device path instead.

AI-generated, human reviewed

)
if resolved_path not in self.block_device_allowlist:
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.

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.

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.

@@ -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).

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

@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
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.

This lockfile diff includes unrelated changes -- jumpstarter-driver-noyito-relay, hid, pytest-mock, and a revision number change (3 to 2). Could these be split out into a separate commit to keep the security fix self-contained?

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.

The lockfile drift is from the branch being behind main. Will rebase onto main to get a clean lockfile diff.

@@ -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.

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.

…stpaths

- Resolve block_device_allowlist entries via os.path.realpath() in
  __post_init__ so operators can use stable symlink paths like
  /dev/disk/by-id/
- Validate that all allowlist entries are absolute paths at init time,
  raising ConfigurationError for relative paths
- Fix pyproject.toml testpaths from ["src"] to
  ["jumpstarter_driver_iscsi"] so tests are discoverable by pytest
- Add tests for allowlist validation and symlink resolution at init

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ambient-code
Copy link
Copy Markdown
Contributor Author

ambient-code bot commented Apr 13, 2026

Review response summary

Addressed review feedback from @raballew (commit bda4152):

Changes made

  1. Resolve allowlist entries at init -- block_device_allowlist entries are now resolved via os.path.realpath() in __post_init__, so operators can use stable symlink paths like /dev/disk/by-id/... in their configuration. This fixes the mismatch where user-supplied paths were resolved but allowlist entries were not.
  2. Validate allowlist entries are absolute -- Non-absolute paths in block_device_allowlist now raise ConfigurationError at init time, catching silent configuration errors early.
  3. Fix testpaths in pyproject.toml -- Changed from ["src"] (nonexistent) to ["jumpstarter_driver_iscsi"] so the new security tests are actually discovered by pytest.
  4. Added tests -- Two new tests covering allowlist validation (ConfigurationError on relative path) and symlink resolution at init time.

Pushed back on

  • Error message info leak -- The resolved path in the error message is intentional; it helps operators debug allowlist mismatches. Untrusted callers receive a generic gRPC error, not the raw exception.
  • @validate_call on zero-param methods -- Follows the established project pattern (opendal driver does the same on every @export method).
  • pydantic as explicit dependency -- Consistent with other drivers; pydantic is a core transitive dependency via jumpstarter. This would be a repo-wide policy change.

Still TODO

  • Rebase onto main to clean up unrelated uv.lock drift (noyito-relay, hid, pytest-mock additions from main).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iSCSI block device exposure - no confinement when is_block=True

2 participants