Security Boundaries and Sandboxing for Geospatial C-Extensions
Geospatial C-extensions such as GDAL, PROJ, GEOS, and rasterio link directly against native spatial libraries, so every malformed GeoTIFF, crafted Shapefile, or hostile WKT string they parse runs inside the same address space as your CPython interpreter. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and narrows the focus to one discipline: drawing enforceable security boundaries around that native code at build time, packaging time, and runtime. It targets cibuildwheel 2.16+, auditwheel 6.x, manylinux_2_28, GDAL ≥ 3.6 / PROJ ≥ 9.2 / GEOS ≥ 3.11, and Linux runners using seccomp-bpf, so that untrusted spatial data can be processed without exposing host infrastructure to native-code vulnerabilities.
Prerequisites & Environment
Sandboxing is only reproducible if the toolchain is pinned. The boundaries below assume the same build image and library versions on every run, so lock them before writing a single compiler flag.
- Build frontend:
cibuildwheel>=2.16drivingbuild>=1.2. Oldercibuildwheelreleases predate theCIBW_CONTAINER_ENGINEisolation options used here. - Repair tooling:
auditwheel>=6.0on Linux. Anything earlier does not understand themanylinux_2_28policy and will silently mis-tag a wheel that bundles glibc-2.28 symbols. - Base image: build inside one of the manylinux_2_28 Docker base images that anchor glibc compliance, never your host distro — the host’s
libpython, glibc, andlibstdc++will otherwise bleed across the boundary into the artifact. - Native libraries: GDAL ≥ 3.6, PROJ ≥ 9.2, GEOS ≥ 3.11, SQLite ≥ 3.40. Whether these are bundled or resolved from the host is decided in Vendoring PROJ and GDAL vs System Libraries; the sandboxing posture here assumes the vendored path.
- Reproducible toolchain: pin the C/C++ compiler and the GDAL/PROJ/GEOS builds through a pixi environment lock file so the hardened binaries you test in CI are byte-for-byte what you ship.
- Runtime kernel: Linux ≥ 5.4 with
CONFIG_SECCOMP_FILTER=yfor theseccomp-bpfworker profile, plus Python ≥ 3.9 foros.add_dll_directoryon the Windows side.
# pixi.toml — pin the native toolchain that the hardening flags compile against
[dependencies]
python = "3.11.*"
gdal = "3.8.*"
proj = "9.3.*"
geos = "3.12.*"
sqlite = "3.45.*"
c-compiler = "*"
cxx-compiler = "*"
Core Configuration
The primary control surface is the cibuildwheel environment block: it injects the hardened compiler and linker flags, neutralises injection vectors like LD_PRELOAD, and installs the build-time spatial dependencies. The flags below enforce stack canaries, fortified libc calls, full RELRO, and immediate symbol binding when compiling against PROJ’s transformation kernels and GDAL’s raster decoders.
# .github/workflows/build.yml — hardened compilation boundary
env:
CFLAGS: "-O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIC"
LDFLAGS: "-Wl,-z,relro,-z,now -Wl,-z,noexecstack"
CIBW_BUILD_VERBOSITY: 1
# Strip any injected preloads from the build environment.
CIBW_ENVIRONMENT: "LD_PRELOAD= DEBIAN_FRONTEND=noninteractive"
CIBW_BEFORE_BUILD: "apt-get update && apt-get install -y --no-install-recommends libgeos-dev libproj-dev libsqlite3-dev"
Use -fPIC, not -fPIE/-pie: the extension is a shared object loaded by the interpreter, not a standalone executable, and -pie will not produce a loadable .so. -fstack-protector-strong inserts canaries around any frame that handles attacker-influenced buffers — exactly the WKB and GeoTIFF parsers — while -D_FORTIFY_SOURCE=2 swaps unbounded memcpy/sprintf calls for length-checked variants. The compiler-level rationale, including how these flags interact with symbol visibility, is expanded in securely compiling spatial C-extensions.
Step-by-Step Implementation
1. Isolate the build container
Run the build inside an ephemeral container with a read-only root filesystem and no outbound network access, so a compromised build script cannot exfiltrate signing keys or pull a tampered dependency mid-build.
# Pin the image by digest and lock down the container at build time.
env:
CIBW_MANYLINUX_X86_64_IMAGE: "quay.io/pypa/manylinux_2_28_x86_64@sha256:<digest>"
CIBW_CONTAINER_ENGINE: "docker; create_args: --network=none --read-only --tmpfs /tmp:exec"
2. Apply the hardened toolchain flags
Export the CFLAGS/LDFLAGS from the Core Configuration above. These are inherited by every native compile in the build, including GDAL’s CMake-driven subprojects when you wire them through the scikit-build-core backend that translates pyproject.toml into CMake invocations.
export CFLAGS="-O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIC"
export LDFLAGS="-Wl,-z,relro,-z,now -Wl,-z,noexecstack"
3. Vendor and contain native libraries
Let auditwheel repair pull the spatial runtimes into the wheel’s private .libs/ directory and rewrite the ELF RPATH to $ORIGIN so dlopen() resolves only the bundled, version-pinned binaries. Reserve --exclude for libraries you deliberately keep external, and never exclude the spatial cores themselves — dropping libgdal, libproj, or libgeos leaves dangling external references and a broken wheel.
# Bundle spatial cores; keep only the platform C++ runtime external.
auditwheel repair --exclude libstdc++.so.6 \
--plat manylinux_2_28_x86_64 \
-w dist/repaired dist/*.whl
4. Restrict the Windows DLL search path
On Windows, replace legacy os.environ["PATH"] mutations with os.add_dll_directory() so the GDAL DLL set resolves from one explicit directory instead of the entire PATH, closing the directory-traversal and DLL-planting vectors. The broader resolution model is covered in shared library path resolution.
import os
from importlib.resources import files
# Scope DLL resolution to the bundled runtime only.
with os.add_dll_directory(str(files("yourpkg").joinpath(".libs"))):
import yourpkg._gdal_ext # noqa: F401
5. Confine the runtime worker
Process untrusted geodata in a dedicated worker that caps its own resources and drops into a seccomp-bpf profile before touching any input. This stops a parser memory bug from spawning shells, attaching debuggers, or opening raw sockets.
import os
import resource
def harden_worker(max_bytes: int = 2 * 1024**3) -> None:
# Cap address space and forbid new processes before parsing.
resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0))
# Install a seccomp-bpf profile (via pyseccomp) that allowlists
# read/write/mmap and kills execve, ptrace, socket, clone.
6. Sandbox external CLI delegation
When you shell out to gdal_translate or ogr2ogr, validate every input path, disable shell interpretation, and drop privileges so the child cannot inherit your service account.
import subprocess
subprocess.run(
["gdal_translate", "-of", "GTiff", validated_in, validated_out],
shell=False, # no shell metacharacter expansion
check=True,
timeout=120,
preexec_fn=os.setuid(65534), # drop to 'nobody'
)
Verification
Each boundary is testable. Confirm the hardening landed before trusting the wheel in production.
Check that the compiled extension carries canaries, RELRO, and a non-executable stack:
checksec --file=yourpkg/_gdal_ext*.so
# Expect: RELRO=Full RELRO STACK CANARY=Canary found NX=NX enabled PIE=DSO
Confirm the spatial runtimes are vendored and that the RPATH points at the wheel’s own directory rather than a host path:
auditwheel show dist/repaired/*.whl # platform tag == manylinux_2_28
unzip -l dist/repaired/*.whl | grep '\.libs/' # libgdal/libproj/libgeos present
ldd yourpkg/_gdal_ext*.so | grep -E 'libproj|libgdal|libgeos'
# Each path must resolve under .libs/, never /usr/lib
Assert the syscall filter actually blocks escalation by proving a forbidden call fails inside the worker:
python -c "import os; harden_worker(); os.execve('/bin/sh', ['sh'], {})"
# Expect: PermissionError: [Errno 1] Operation not permitted
Optimization & Edge Cases
Caching without weakening isolation. A read-only rootfs and --network=none defeat naive dependency caching. Mount a pre-populated, digest-pinned wheelhouse as a read-only volume rather than re-enabling network egress, so the cache cannot become an injection vector.
musl vs glibc canary differences. On musllinux targets the stack-protector runtime differs from glibc, and -D_FORTIFY_SOURCE=2 coverage is narrower in musl’s libc. Build a separate hardened matrix entry for musl rather than assuming glibc parity; the trade-offs mirror those in manylinux2014 vs musllinux for spatial libs.
seccomp and memory-mapped raster I/O. GDAL’s virtual file system and large-raster reads rely heavily on mmap, pread, and madvise. An over-tight allowlist will kill legitimate raster access with SIGSYS. Trace the worker under strace -f against a representative GeoTIFF before locking the profile, and allowlist the file-I/O syscalls GDAL genuinely needs.
FFI heap corruption at the ABI seam. Unguarded ctypes/cffi calls can corrupt the interpreter heap independent of any sandbox, because they share the allocator the C-API vs CPython ABI compatibility boundary governs. Wrap raw FFI calls in context managers that validate pointer non-nullity and buffer length before crossing into C-space; ownership and teardown details live in memory management in geospatial extensions.
Supply-chain provenance. Generate an SBOM with syft or cyclonedx-py for every wheel and verify the checksum of each downloaded .tar.gz/.whl before it enters integration testing, so a tampered native dependency is caught at the boundary rather than at runtime.
Troubleshooting
OSError: cannot load library 'libgdal.so.34': /usr/lib/x86_64-linux-gnu/libgdal.so.34: undefined symbol: ...
The loader resolved a host GDAL instead of the vendored one — the RPATH containment leaked. Re-run auditwheel repair so the .so gets an $ORIGIN-relative RPATH, and confirm with ldd that no spatial library resolves under /usr/lib. A stray LD_LIBRARY_PATH in the runtime environment will also override $ORIGIN; unset it in the worker.
relocation R_X86_64_32 against '.rodata' can not be used when making a shared object; recompile with -fPIC
A native subproject was compiled without -fPIC, usually because CFLAGS was overridden by GDAL’s own CMake cache. Export the hardened CFLAGS and pass -DCMAKE_POSITION_INDEPENDENT_CODE=ON so every object in the dependency tree is position-independent.
SIGSYS / Bad system call (core dumped) when reading a raster
The seccomp-bpf allowlist is missing a syscall GDAL needs for memory-mapped I/O (commonly mmap, madvise, pread64, or statx). Run the worker under strace -f -e trace=all against a known-good GeoTIFF, identify the killed syscall, and add it to the allowlist rather than disabling the filter.
auditwheel: error: cannot repair "dist/x.whl" to "manylinux_2_28" ... requires "libstdc++.so.6" with version "GLIBCXX_3.4.30"
You excluded libstdc++ but built against a newer GCC than the base image ships. Either build inside the pinned manylinux_2_28 image so the C++ runtime matches, or drop the --exclude libstdc++.so.6 and let auditwheel vendor it.
Related
- Geospatial C-Extension Fundamentals & ABI Architecture — the parent reference covering compile, link, repair, and import for native geospatial extensions.
- securely compiling spatial C-extensions — the compiler-flag and ABI-validation detail behind the build boundary, with an error-to-fix matrix.
- Vendoring PROJ and GDAL vs System Libraries — the decision that determines whether your sandbox bundles the spatial cores or trusts the host.
- shared library path resolution — how
RPATH,$ORIGIN, and DLL directories are resolved across Linux, macOS, and Windows. - C-API vs CPython ABI compatibility — the interpreter-allocator boundary that unguarded FFI calls can corrupt from inside the sandbox.
Further Reading
- Linux seccomp BPF documentation (
docs.kernel.org/userspace-api/seccomp_filter.html). - Python subprocess security considerations (
docs.python.org/3/library/subprocess.html#security-considerations).