Securely compiling spatial C-extensions
Compiling GDAL, PROJ, and GEOS C-extensions inside a CI/CD job means running an untrusted, network-capable native build toolchain — this page shows how to sandbox that build so the resulting wheel cannot exfiltrate secrets, pollute the host, or ship an ABI that fails at import time. It sits under the Security Boundaries and Sandboxing cluster, part of the broader Geospatial C-Extension Fundamentals & ABI Architecture reference.
Context & Root Cause
A spatial extension build is a security boundary that most pipelines leave open. Three failure classes converge here. First, the build itself is untrusted code: setup.py, CMake scripts, and the GDAL/PROJ configure stages run arbitrary commands with whatever credentials the runner holds, so an unscoped runner leaks PYPI_TOKEN or AWS_* the moment a transitive dependency is compromised. Second, host toolchain leakage: compiling on a bare-metal runner that already has system GDAL silently links the extension against /usr/lib/libgdal.so, baking in an ABI the wheel will never find on a user’s machine. Third, overly aggressive sandboxes break the build — a strict container seccomp profile blocks getrandom, and the compile aborts before it starts.
The fix is not “lock everything down” but “compile in a reproducible, capability-minimal container with hardened linker flags and vendored dependencies,” so the boundary is tight enough to contain a compromised build yet permissive enough to let the toolchain run.
Solution / Fix
Prerequisites: a pinned manylinux_2_28 Docker base image, auditwheel ≥ 6.0, Docker ≥ 24 (for --security-opt), and a build that never assumes system GDAL is present.
1. Triage the failure signature first
When a hardened build breaks, map the signature to its root cause before changing config. These strings are quoted verbatim so they match the errors you will actually see:
| Error signature | Root cause | Fix |
|---|---|---|
ImportError: /opt/_core.so: undefined symbol: proj_create_from_wkt |
ABI drift between the compile toolchain and the linked PROJ; symbols not hidden, so the loader resolves against the wrong libproj. |
Compile with -fvisibility=hidden and link the vendored PROJ archive explicitly; verify with nm -D. |
auditwheel: error: cannot repair wheel ... it contains external references to libgdal.so.34 |
Unvendored system dependency violates the manylinux policy; the dynamic linker will fail on hosts without that exact .so. |
Run auditwheel repair, or weigh vendoring PROJ and GDAL vs system libraries. |
OSError: [Errno 38] Function not implemented (during getrandom) |
Container seccomp profile blocks the getrandom syscall that OpenSSL/os.urandom needs at build time. |
Whitelist getrandom in a custom seccomp JSON, or relax seccomp for the build step only. |
pyproj.exceptions.DataDirError: Valid PROJ data directory not found |
The proj.db data tree was not embedded or PROJ_LIB does not resolve at runtime. |
Bundle share/proj into the wheel and set PROJ_LIB relative to __file__ at import. |
2. Compile in a capability-minimal container
Never compile on a bare-metal runner. Drop every Linux capability, mount the source read-only, and forbid privilege escalation, then add back only what the toolchain genuinely needs:
docker run --rm \
--cap-drop=ALL \
--cap-add=SYS_PTRACE \
--security-opt=no-new-privileges \
--security-opt=seccomp=/etc/ci/spatial-seccomp.json \
--network=none \
-v "$(pwd)":/src:ro \
-v /tmp/wheels:/dist \
quay.io/pypa/manylinux_2_28_x86_64:latest \
bash -c "cd /src && pip wheel . --no-deps --no-build-isolation --wheel-dir /dist"
--network=none is the strongest single control: a compromised build cannot exfiltrate credentials with no route off the host. Use a custom seccomp profile rather than seccomp=unconfined so getrandom is allowed without opening the whole syscall surface. The same isolation discipline drives reproducible environments through pixi-managed build environments, which pin every native dependency by hash.
3. Harden the compile and link flags
Spatial extensions fracture when the CPython ABI expectation diverges from the toolchain. Enforce symbol visibility, full RELRO, and $ORIGIN-relative search paths so the binary cannot be hijacked through GOT overwrites or a planted rpath:
export CFLAGS="-O2 -fPIC -fvisibility=hidden -D_GLIBCXX_ASSERTIONS"
export LDFLAGS="-Wl,-z,relro,-z,now -Wl,--as-needed -Wl,-rpath,\$ORIGIN/../.libs"
-fvisibility=hiddenrestricts the export table to explicitly marked functions, eliminating the symbol collisions behindundefined symbolimport failures.-Wl,-z,relro,-z,nowenables full RELRO, mapping the GOT read-only after relocation.-Wl,--as-neededdrops shared libraries the extension never calls, shrinking theDT_NEEDEDsetauditwheelmust satisfy.-Wl,-rpath,\$ORIGIN/../.libsembeds a relative runtime search path for the vendored.sofiles — the mechanics are detailed in managing shared library paths in manylinux.
4. Vendor dependencies and embed PROJ data
A manylinux wheel must carry every non-stdlib .so. Repair the wheel so external libraries are copied in and RPATH is rewritten, then embed the PROJ data tree so runtime lookups never touch the host:
auditwheel repair --plat manylinux_2_28_x86_64 /dist/*.whl -w /wheelhouse
# mygis/__init__.py — resolve PROJ data relative to the package, not the host
import os
_pkg_dir = os.path.dirname(os.path.abspath(__file__))
os.environ.setdefault("PROJ_LIB", os.path.join(_pkg_dir, "proj_data"))
5. Wire it into the pipeline
jobs:
build-spatial:
runs-on: ubuntu-latest
container:
image: quay.io/pypa/manylinux_2_28_x86_64:latest
options: --cap-drop=ALL --security-opt=no-new-privileges
steps:
- uses: actions/checkout@v4
- name: Compile and repair
run: |
export CFLAGS="-O2 -fPIC -fvisibility=hidden"
export LDFLAGS="-Wl,-z,relro,-z,now"
pip wheel . --no-deps --no-build-isolation --wheel-dir /dist
auditwheel repair --plat manylinux_2_28_x86_64 /dist/*.whl -w /wheelhouse
Verification
Run these against the repaired wheel before publishing. ELF tools read the .so, not the .whl zip, so extract first:
# 1. Confirm hardened ELF properties (RELRO + hidden symbols)
unzip -o -q /wheelhouse/*.whl -d /tmp/wh
readelf -d /tmp/wh/mygis/_core*.so | grep -E "BIND_NOW|FLAGS"
# Expected: FLAGS BIND_NOW and FLAGS_1 Flags: NOW
# 2. Confirm no external library leaks past the wheel
auditwheel show /wheelhouse/*.whl
# Expected: "...is consistent with the following platform tag: manylinux_2_28_x86_64"
# and no "external references" warnings
# 3. Confirm a clean runtime import with no host PROJ/GDAL present
docker run --rm -v /wheelhouse:/w --network=none quay.io/pypa/manylinux_2_28_x86_64 \
bash -c "pip install /w/*.whl && python -c 'import mygis; print(\"ABI OK\")'"
# Expected: ABI OK
If step 2 reports external references, the dependency was not vendored and the wheel will raise cannot open shared object file on any host missing that library.
Pitfalls & Alternatives
- Reaching for
--security-opt=seccomp=unconfinedthe momentgetrandomis blocked. Disabling seccomp entirely to fix one syscall reopens the whole boundary — a compromised build regainsptrace,mount, and raw socket access. Whitelist the single syscall in a custom profile instead and keep--network=none. - Exporting
LD_LIBRARY_PATHso the build “finds” libgdal. This makes the link succeed by pulling in the host’s system GDAL, baking the host ABI into the wheel; it then fails withundefined symbolon every other machine. Vendor the library and pinRPATHto$ORIGIN—LD_LIBRARY_PATHshould never appear in a distributable build. - Statically linking everything to “avoid the vendoring problem.” A fully static GDAL/PROJ build defeats
auditwheel’s ability to dedupe and patch shared objects, bloats the wheel, and breaks PROJ’s runtimeproj.dblookup. Prefer dynamic linking plusauditwheel repair; reserve static linking for PROJ only, where the data-directory cost is documented in why vendoring PROJ causes wheel bloat.
Related
- Security Boundaries and Sandboxing — the parent cluster on threat modeling and CI/CD containment for spatial extensions.
- Managing shared library paths in manylinux — how
RPATH,RUNPATH, and$ORIGINdecide whichlibgdalloads at runtime. - Vendoring PROJ and GDAL vs system libraries — the decision that determines whether your hardened build is self-contained or host-dependent.
Further reading: the platform policy enforced by
auditwheelis defined by the PyPA manylinux specification (PEP 600).