Skip to content

Commit ce44526

Browse files
Baughnclaude
andcommitted
feat: Add Landlock LSM sandbox for filesystem isolation
Implements Linux Landlock sandboxing to restrict filesystem access when ComfyUI is running. This provides defense-in-depth against malicious custom nodes or workflows that attempt to access sensitive files. How it works: - Uses Linux Landlock LSM (kernel 5.13+) via direct syscalls - Restricts write access to specific directories (output, input, temp, user) - Restricts read access to only what's needed (codebase, models, system libs) - Handles ABI versions 1-5, including IOCTL_DEV for GPU access on v5+ - Exits with error if --enable-landlock is set but Landlock unavailable Write access granted to: - ComfyUI output, input, temp, and user directories - System temp directory (for torch/backends) - SQLite database directory (if configured) - Paths specified via --landlock-allow-writable Read access granted to: - ComfyUI codebase directory - All configured model directories (including extra_model_paths.yaml) - Python installation and site-packages - System libraries (/usr, /lib, /lib64, /opt, /etc, /proc, /sys) - /nix (on NixOS systems) - /dev (with ioctl for GPU access) - Paths specified via --landlock-allow-readable Usage: python main.py --enable-landlock python main.py --enable-landlock --landlock-allow-writable /extra/dir python main.py --enable-landlock --landlock-allow-readable ~/.cache/huggingface Requirements: - Linux with kernel 5.13+ (fails with error on unsupported systems) - Once enabled, restrictions cannot be lifted for the process lifetime - Network access is not restricted (Landlock FS only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 52e778f commit ce44526

File tree

3 files changed

+343
-0
lines changed

3 files changed

+343
-0
lines changed

comfy/cli_args.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def __call__(self, parser, namespace, values, option_string=None):
4747
parser.add_argument("--output-directory", type=str, default=None, help="Set the ComfyUI output directory. Overrides --base-directory.")
4848
parser.add_argument("--temp-directory", type=str, default=None, help="Set the ComfyUI temp directory (default is in the ComfyUI directory). Overrides --base-directory.")
4949
parser.add_argument("--input-directory", type=str, default=None, help="Set the ComfyUI input directory. Overrides --base-directory.")
50+
parser.add_argument("--enable-landlock", action="store_true", help="Use the Linux Landlock LSM to restrict filesystem writes to known ComfyUI and cache directories.")
51+
parser.add_argument("--landlock-allow-writable", action="append", default=[], metavar="PATH", help="Extra directories that remain writable when --enable-landlock is set. Can be provided multiple times.")
52+
parser.add_argument("--landlock-allow-readable", action="append", default=[], metavar="PATH", help="Extra directories to allow read access when --enable-landlock is set. Can be provided multiple times.")
5053
parser.add_argument("--auto-launch", action="store_true", help="Automatically launch ComfyUI in the default browser.")
5154
parser.add_argument("--disable-auto-launch", action="store_true", help="Disable auto launching the browser.")
5255
parser.add_argument("--cuda-device", type=int, default=None, metavar="DEVICE_ID", help="Set the id of the cuda device this instance will use. All other devices will not be visible.")

main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from app.logger import setup_logger
1010
import itertools
1111
import utils.extra_config
12+
import utils.landlock
1213
import logging
1314
import sys
1415
from comfy_execution.progress import get_progress_state
@@ -311,6 +312,13 @@ def start_comfyui(asyncio_loop=None):
311312
folder_paths.set_temp_directory(temp_dir)
312313
cleanup_temp()
313314

315+
if args.enable_landlock:
316+
logging.info("Enabling Landlock")
317+
landlock_ok = utils.landlock.enable_landlock(args, logging.getLogger("landlock"))
318+
if not landlock_ok:
319+
logging.critical("Requested Landlock sandbox but it could not be enabled. Exiting.")
320+
sys.exit(1)
321+
314322
if args.windows_standalone_build:
315323
try:
316324
import new_updater

utils/landlock.py

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import ctypes
2+
import errno
3+
import logging
4+
import os
5+
import sys
6+
import tempfile
7+
from dataclasses import dataclass
8+
9+
# Landlock constants copied from linux/landlock.h
10+
PR_SET_NO_NEW_PRIVS = 38
11+
12+
LANDLOCK_RULE_PATH_BENEATH = 1
13+
LANDLOCK_CREATE_RULESET_VERSION = 1
14+
15+
LANDLOCK_ACCESS_FS_EXECUTE = 1 << 0
16+
LANDLOCK_ACCESS_FS_WRITE_FILE = 1 << 1
17+
LANDLOCK_ACCESS_FS_READ_FILE = 1 << 2
18+
LANDLOCK_ACCESS_FS_READ_DIR = 1 << 3
19+
LANDLOCK_ACCESS_FS_REMOVE_DIR = 1 << 4
20+
LANDLOCK_ACCESS_FS_REMOVE_FILE = 1 << 5
21+
LANDLOCK_ACCESS_FS_MAKE_CHAR = 1 << 6
22+
LANDLOCK_ACCESS_FS_MAKE_DIR = 1 << 7
23+
LANDLOCK_ACCESS_FS_MAKE_REG = 1 << 8
24+
LANDLOCK_ACCESS_FS_MAKE_SOCK = 1 << 9
25+
LANDLOCK_ACCESS_FS_MAKE_FIFO = 1 << 10
26+
LANDLOCK_ACCESS_FS_MAKE_BLOCK = 1 << 11
27+
LANDLOCK_ACCESS_FS_MAKE_SYM = 1 << 12
28+
LANDLOCK_ACCESS_FS_REFER = 1 << 13
29+
LANDLOCK_ACCESS_FS_TRUNCATE = 1 << 14
30+
LANDLOCK_ACCESS_FS_IOCTL_DEV = 1 << 15 # ABI v5+
31+
32+
# Pre-computed access masks
33+
FS_READ_ACCESS = (
34+
LANDLOCK_ACCESS_FS_READ_FILE
35+
| LANDLOCK_ACCESS_FS_READ_DIR
36+
| LANDLOCK_ACCESS_FS_EXECUTE
37+
)
38+
FS_WRITE_ACCESS = (
39+
FS_READ_ACCESS
40+
| LANDLOCK_ACCESS_FS_WRITE_FILE
41+
| LANDLOCK_ACCESS_FS_MAKE_DIR
42+
| LANDLOCK_ACCESS_FS_MAKE_REG
43+
| LANDLOCK_ACCESS_FS_MAKE_SOCK
44+
| LANDLOCK_ACCESS_FS_MAKE_FIFO
45+
| LANDLOCK_ACCESS_FS_MAKE_BLOCK
46+
| LANDLOCK_ACCESS_FS_MAKE_CHAR
47+
| LANDLOCK_ACCESS_FS_MAKE_SYM
48+
| LANDLOCK_ACCESS_FS_REMOVE_DIR
49+
| LANDLOCK_ACCESS_FS_REMOVE_FILE
50+
)
51+
52+
# Syscall numbers are ABI-stable across all 64-bit Linux architectures
53+
SYS_LANDLOCK_CREATE_RULESET = 444
54+
SYS_LANDLOCK_ADD_RULE = 445
55+
SYS_LANDLOCK_RESTRICT_SELF = 446
56+
57+
58+
class _RulesetAttr(ctypes.Structure):
59+
_fields_ = [("handled_access_fs", ctypes.c_uint64)]
60+
61+
62+
class _PathBeneathAttr(ctypes.Structure):
63+
_fields_ = [
64+
("allowed_access", ctypes.c_uint64),
65+
("parent_fd", ctypes.c_int32),
66+
("reserved", ctypes.c_uint32),
67+
]
68+
69+
70+
@dataclass(frozen=True)
71+
class LandlockRules:
72+
read_paths: set[str]
73+
write_paths: set[str]
74+
ioctl_paths: set[str]
75+
76+
77+
def _normalize_paths(paths: set[str]) -> set[str]:
78+
normalized = set()
79+
for path in paths:
80+
if not path:
81+
continue
82+
normalized.add(os.path.realpath(path))
83+
return normalized
84+
85+
86+
class LandlockEnforcer:
87+
def __init__(self, logger: logging.Logger | None = None):
88+
self.log = logger or logging.getLogger(__name__)
89+
self.libc = ctypes.CDLL(None, use_errno=True)
90+
self.libc.syscall.restype = ctypes.c_long
91+
self.libc.prctl.restype = ctypes.c_int
92+
93+
def _syscall(self, syscall_nr, *args) -> tuple[int | None, int]:
94+
ctypes.set_errno(0)
95+
res = self.libc.syscall(ctypes.c_long(syscall_nr), *args)
96+
if res == -1:
97+
return None, ctypes.get_errno()
98+
return res, 0
99+
100+
def _abi_version(self) -> int:
101+
res, err = self._syscall(
102+
SYS_LANDLOCK_CREATE_RULESET,
103+
ctypes.c_void_p(0),
104+
ctypes.c_size_t(0),
105+
ctypes.c_uint(LANDLOCK_CREATE_RULESET_VERSION),
106+
)
107+
if res is None:
108+
if err in (errno.ENOSYS, errno.EOPNOTSUPP):
109+
return 0
110+
return -err
111+
return res
112+
113+
def _create_ruleset(self, handled_access: int) -> tuple[int | None, int]:
114+
ruleset = _RulesetAttr(ctypes.c_uint64(handled_access))
115+
return self._syscall(
116+
SYS_LANDLOCK_CREATE_RULESET,
117+
ctypes.byref(ruleset),
118+
ctypes.c_size_t(ctypes.sizeof(ruleset)),
119+
ctypes.c_uint(0),
120+
)
121+
122+
def _add_rule(self, ruleset_fd: int, path: str, access_mask: int, allow_ioctl: bool) -> bool:
123+
if allow_ioctl:
124+
access_mask |= LANDLOCK_ACCESS_FS_IOCTL_DEV
125+
126+
try:
127+
dir_fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
128+
except OSError as exc:
129+
self.log.warning("Landlock: skipping %s (%s)", path, exc)
130+
return False
131+
132+
try:
133+
rule = _PathBeneathAttr(
134+
ctypes.c_uint64(access_mask), ctypes.c_int32(dir_fd), ctypes.c_uint32(0)
135+
)
136+
res, err = self._syscall(
137+
SYS_LANDLOCK_ADD_RULE,
138+
ctypes.c_int(ruleset_fd),
139+
ctypes.c_int(LANDLOCK_RULE_PATH_BENEATH),
140+
ctypes.byref(rule),
141+
ctypes.c_uint(0),
142+
)
143+
if res is None:
144+
self.log.warning("Landlock: failed to add %s (errno=%s)", path, err)
145+
return False
146+
return True
147+
finally:
148+
os.close(dir_fd)
149+
150+
def _restrict_self(self, ruleset_fd: int) -> bool:
151+
if self.libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
152+
self.log.warning(
153+
"Landlock: prctl(PR_SET_NO_NEW_PRIVS) failed (errno=%s)",
154+
ctypes.get_errno(),
155+
)
156+
return False
157+
158+
res, err = self._syscall(SYS_LANDLOCK_RESTRICT_SELF, ctypes.c_int(ruleset_fd), ctypes.c_uint(0))
159+
if res is None:
160+
self.log.warning("Landlock: restrict_self failed (errno=%s)", err)
161+
return False
162+
return True
163+
164+
def apply(self, rules: LandlockRules) -> bool:
165+
if not sys.platform.startswith("linux"):
166+
self.log.info("Landlock: not a Linux platform, skipping.")
167+
return False
168+
169+
abi_version = self._abi_version()
170+
if abi_version <= 0:
171+
self.log.info("Landlock: not available on this kernel (abi=%s).", abi_version)
172+
return False
173+
174+
read_access = FS_READ_ACCESS
175+
handled_write_access = FS_WRITE_ACCESS
176+
allowed_write_access = (
177+
read_access
178+
| LANDLOCK_ACCESS_FS_WRITE_FILE
179+
| LANDLOCK_ACCESS_FS_MAKE_DIR
180+
| LANDLOCK_ACCESS_FS_MAKE_REG
181+
| LANDLOCK_ACCESS_FS_REMOVE_DIR
182+
| LANDLOCK_ACCESS_FS_REMOVE_FILE
183+
) # leave other handled rights (symlinks, device nodes, sockets) denied
184+
if abi_version >= 2:
185+
handled_write_access |= LANDLOCK_ACCESS_FS_TRUNCATE
186+
allowed_write_access |= LANDLOCK_ACCESS_FS_TRUNCATE
187+
if abi_version >= 3:
188+
handled_write_access |= LANDLOCK_ACCESS_FS_REFER
189+
allowed_write_access |= LANDLOCK_ACCESS_FS_REFER
190+
191+
handled_access = handled_write_access | read_access
192+
ioctl_supported = abi_version >= 5
193+
if ioctl_supported:
194+
handled_access |= LANDLOCK_ACCESS_FS_IOCTL_DEV
195+
196+
write_paths = _normalize_paths(rules.write_paths)
197+
read_paths = _normalize_paths(rules.read_paths) - write_paths
198+
# In theory these could require write or read access. Though in practice it's just /dev.
199+
ioctl_paths = _normalize_paths(rules.ioctl_paths)
200+
201+
if ioctl_paths and not ioctl_supported:
202+
self.log.info(
203+
"Landlock: ioctl access requested but ABI %s has no support; continuing without ioctl.",
204+
abi_version,
205+
)
206+
207+
ruleset_fd = None
208+
ruleset_fd, err = self._create_ruleset(handled_access)
209+
if ruleset_fd is None:
210+
self.log.warning("Landlock: failed to create ruleset (errno=%s)", err)
211+
return False
212+
213+
try:
214+
for path in write_paths:
215+
if path != os.path.sep:
216+
try:
217+
os.makedirs(path, exist_ok=True)
218+
except Exception as exc:
219+
self.log.warning("Landlock: unable to prepare %s (%s)", path, exc)
220+
return False
221+
if not self._add_rule(
222+
ruleset_fd, path, allowed_write_access, ioctl_supported and path in ioctl_paths
223+
):
224+
return False
225+
226+
for path in read_paths:
227+
self._add_rule(ruleset_fd, path, read_access, ioctl_supported and path in ioctl_paths)
228+
229+
if not self._restrict_self(ruleset_fd):
230+
return False
231+
finally:
232+
if ruleset_fd is not None:
233+
os.close(ruleset_fd)
234+
235+
if write_paths:
236+
self.log.info(
237+
"Landlock enabled (ABI %s). Writable roots: %s",
238+
abi_version,
239+
", ".join(sorted(write_paths)),
240+
)
241+
else:
242+
self.log.info("Landlock enabled (ABI %s). No writable roots configured.", abi_version)
243+
return True
244+
245+
246+
_landlock_applied = False
247+
248+
249+
def build_default_rules(args) -> LandlockRules:
250+
import folder_paths
251+
from urllib.parse import urlparse
252+
253+
write_paths: set[str] = {
254+
folder_paths.get_output_directory(),
255+
folder_paths.get_input_directory(),
256+
folder_paths.get_temp_directory(),
257+
folder_paths.get_user_directory(),
258+
}
259+
260+
ioctl_paths: set[str] = set()
261+
262+
# Torch and some backends use system temp and /dev/shm
263+
write_paths.add(tempfile.gettempdir())
264+
265+
if args.temp_directory:
266+
write_paths.add(os.path.join(os.path.abspath(args.temp_directory), "temp"))
267+
268+
db_url = getattr(args, "database_url", None)
269+
if db_url and db_url.startswith("sqlite"):
270+
parsed = urlparse(db_url)
271+
if parsed.scheme == "sqlite" and parsed.path:
272+
write_paths.add(os.path.abspath(os.path.dirname(parsed.path)))
273+
274+
for path in args.landlock_allow_writable or []:
275+
if path:
276+
write_paths.add(path)
277+
278+
# Build read paths - only what's actually needed
279+
read_paths: set[str] = set()
280+
281+
# ComfyUI codebase
282+
read_paths.add(folder_paths.base_path)
283+
284+
# All configured model directories (includes extra_model_paths.yaml)
285+
for folder_name in folder_paths.folder_names_and_paths:
286+
for path in folder_paths.folder_names_and_paths[folder_name][0]:
287+
read_paths.add(path)
288+
289+
# Python installation and site-packages
290+
read_paths.add(sys.prefix)
291+
if sys.base_prefix != sys.prefix:
292+
read_paths.add(sys.base_prefix)
293+
for path in sys.path:
294+
if path and os.path.isdir(path):
295+
read_paths.add(path)
296+
297+
# System libraries (required for shared libs, CUDA, etc.)
298+
for system_path in ["/usr", "/lib", "/lib64", "/opt", "/etc", "/proc", "/sys"]:
299+
if os.path.exists(system_path):
300+
read_paths.add(system_path)
301+
302+
# NixOS: /nix/store contains the entire system
303+
if os.path.exists("/nix"):
304+
read_paths.add("/nix")
305+
306+
# /dev needs write + ioctl for CUDA/GPU access
307+
write_paths.add("/dev")
308+
ioctl_paths.add("/dev")
309+
310+
# User-specified additional read paths
311+
for path in getattr(args, "landlock_allow_readable", None) or []:
312+
if path:
313+
read_paths.add(path)
314+
315+
return LandlockRules(read_paths=_normalize_paths(read_paths), write_paths=_normalize_paths(write_paths), ioctl_paths=_normalize_paths(ioctl_paths))
316+
317+
318+
def enable_landlock(args, logger: logging.Logger | None = None) -> bool:
319+
global _landlock_applied
320+
321+
if _landlock_applied:
322+
return True
323+
if not getattr(args, "enable_landlock", False):
324+
return False
325+
326+
enforcer = LandlockEnforcer(logger)
327+
try:
328+
_landlock_applied = enforcer.apply(build_default_rules(args))
329+
except Exception:
330+
enforcer.log.exception("Landlock: unexpected failure while applying ruleset.")
331+
_landlock_applied = False
332+
return _landlock_applied

0 commit comments

Comments
 (0)