ifcurl/ifcurl/sandbox.py
Bruno Postle 10ea3ca071 Fix license headers: correct copyright owner and add SPDX identifiers
JS files had wrong copyright owner (The Forgejo Authors → Bruno Postle).
Python files lacked SPDX-License-Identifier: LGPL-3.0-or-later.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:07:23 +01:00

149 lines
4.9 KiB
Python

# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
# SPDX-License-Identifier: LGPL-3.0-or-later
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""Subprocess sandbox for ifcopenshell operations.
ifcopenshell uses C++ bindings that can segfault on malformed or adversarially
crafted IFC data. Running untrusted IFC through the main service process
exposes it to DoS (segfault kills the worker) and potential RCE (memory
corruption).
``run_sandboxed`` executes a callable in a short-lived child process. If the
child exits with a fatal signal (SIGSEGV, SIGABRT, …) the parent raises
``SandboxCrashError`` instead of dying. If the child exceeds the configured
timeout, the parent raises ``SandboxTimeoutError`` and kills the child.
Resource limits
---------------
Set ``IFCURL_SANDBOX_MEMORY_MB`` to cap the child's virtual address space (in
MiB). Set ``IFCURL_SANDBOX_CPU_SECS`` to cap CPU time (in seconds). Both
default to 0 (no limit) so that out-of-the-box behaviour is unchanged; set
them in production to bound worst-case resource usage.
Timeout
-------
``IFCURL_SANDBOX_TIMEOUT`` (default 120 s) applies per-call. The timeout
should be longer than the longest expected render time so that slow-but-valid
models aren't rejected.
"""
from __future__ import annotations
import multiprocessing
import os
try:
import resource as _resource
_HAS_RESOURCE = True
except ImportError:
_HAS_RESOURCE = False # Windows
_SANDBOX_TIMEOUT: int = int(os.environ.get("IFCURL_SANDBOX_TIMEOUT", "120"))
_SANDBOX_MEMORY_MB: int = int(os.environ.get("IFCURL_SANDBOX_MEMORY_MB", "0"))
_SANDBOX_CPU_SECS: int = int(os.environ.get("IFCURL_SANDBOX_CPU_SECS", "0"))
class SandboxError(RuntimeError):
pass
class SandboxCrashError(SandboxError):
"""Child process was killed by a signal (segfault, abort, …)."""
class SandboxTimeoutError(SandboxError):
"""Child process exceeded the allowed wall-clock time."""
def _worker(
conn: multiprocessing.connection.Connection, fn, args: tuple, kwargs: dict
) -> None:
"""Entry point for the sandboxed child process."""
if _HAS_RESOURCE:
if _SANDBOX_MEMORY_MB > 0:
limit = _SANDBOX_MEMORY_MB * 1024 * 1024
_resource.setrlimit(_resource.RLIMIT_AS, (limit, limit))
if _SANDBOX_CPU_SECS > 0:
_resource.setrlimit(
_resource.RLIMIT_CPU, (_SANDBOX_CPU_SECS, _SANDBOX_CPU_SECS)
)
try:
result = fn(*args, **kwargs)
conn.send(("ok", result))
except BaseException as exc:
try:
conn.send(("err", exc))
except Exception:
pass # if the exception is not picklable, the parent sees EOFError
finally:
conn.close()
def run_sandboxed(fn, *args, timeout: int = _SANDBOX_TIMEOUT, **kwargs):
"""Run ``fn(*args, **kwargs)`` in a child process.
:returns: The return value of *fn* on success.
:raises SandboxCrashError: If the child exits with a signal.
:raises SandboxTimeoutError: If the child exceeds *timeout* seconds.
:raises: Any exception raised by *fn* inside the child (re-raised as-is).
"""
parent_conn, child_conn = multiprocessing.Pipe(duplex=False)
proc = multiprocessing.Process(
target=_worker,
args=(child_conn, fn, args, kwargs),
daemon=True,
)
proc.start()
child_conn.close()
got_data = parent_conn.poll(timeout)
if not got_data:
parent_conn.close()
proc.terminate()
proc.join(5)
if proc.is_alive():
proc.kill()
proc.join()
raise SandboxTimeoutError(f"Subprocess timed out after {timeout}s")
try:
status, value = parent_conn.recv()
except EOFError:
parent_conn.close()
proc.join()
sig = (
-proc.exitcode
if (proc.exitcode is not None and proc.exitcode < 0)
else proc.exitcode
)
raise SandboxCrashError(
f"Subprocess terminated without sending result (exit {sig})"
)
parent_conn.close()
proc.join()
if proc.exitcode is not None and proc.exitcode < 0:
raise SandboxCrashError(f"Subprocess killed by signal {-proc.exitcode}")
if status == "err":
raise value
return value