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 section, part of the broader Geospatial C-Extension Fundamentals & ABI Architecture reference.

The hardened spatial-extension build pipeline and the boundary each stage enforces A left-to-right pipeline of six stages: a read-only source mount feeds a capability-dropped manylinux container (cap-drop=ALL, no-new-privileges, network=none); the container runs a hardened compile with hidden symbol visibility, full RELRO and PIE; the build vendors libgdal and libproj with an $ORIGIN-relative rpath; auditwheel repair rewrites RPATH and bundles the shared objects; the output is a validated wheel that imports cleanly. Three callouts below show the security boundaries enforced: the isolation boundary (network=none and cap-drop=ALL) stops a compromised build from leaking credentials or polluting the host; ABI hardening keeps exported symbols private so there is no undefined-symbol drift or GOT overwrite; self-containment via auditwheel makes the wheel satisfy the manylinux policy without ever linking the host libgdal. Hardened spatial-extension build — and the boundary each stage holds source flows left to right; dashed lines mark where a control contains a compromised or drifting build Read-only source mount -v src:ro Cap-dropped container --cap-drop=ALL --network=none Hardened compile -fvisibility=hidden RELRO · PIE Vendor libgdal / libproj $ORIGIN/../.libs auditwheel repair rewrite RPATH Validated wheel import OK Isolation boundary --network=none · --cap-drop=ALL a compromised build has no route to leak PYPI_TOKEN or pollute the host ABI hardening -fvisibility=hidden · -z relro,now exported symbols stay private — no undefined-symbol drift, no GOT overwrite Self-containment auditwheel repair vendored .so satisfies the manylinux policy — never links the host libgdal

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.

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=hidden restricts the export table to explicitly marked functions, eliminating the symbol collisions behind undefined symbol import failures.
  • -Wl,-z,relro,-z,now enables full RELRO, mapping the GOT read-only after relocation.
  • -Wl,--as-needed drops shared libraries the extension never calls, shrinking the DT_NEEDED set auditwheel must satisfy.
  • -Wl,-rpath,\$ORIGIN/../.libs embeds a relative runtime search path for the vendored .so files — 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.

What a Hardened Build Looks Like Under checksec

Hardening is only real if it can be observed on the shipped binary. checksec reads the properties directly out of the ELF file, and a hardened spatial extension has a recognisable profile.

The checksec profile of a hardened versus an unhardened spatial extension Six properties compared between an unhardened default build and a hardened one. Relocation read-only is partial in the default and full when built with the relro and now linker flags. Stack canaries are absent by default and present with the stack protector flag. Non-executable stack is present in both. Position-independent code is present in both because a shared object requires it. Fortified source functions number zero by default and several dozen when FORTIFY_SOURCE is defined. Control-flow protection is absent by default and present with the cf-protection flag. default build hardened build RELRO partial full stack canary not found found NX stack enabled enabled PIE / PIC DSO DSO fortified functions 0 40 of 62 control-flow protection no yes the two rows that never change are the ones a shared object gets for free — the other four are the ones your flags decide

The fortified-function count is the most informative single number, because it is evidence that the flag reached the compiler rather than being silently dropped. A build that claims -D_FORTIFY_SOURCE and reports zero fortified functions almost always compiled without optimisation, since the fortification only applies when the compiler can evaluate sizes at compile time.

Hardening the Vendored Libraries Too

The extension is the smallest part of the attack surface. Almost all the parsing code in a spatial wheel lives in the libraries it bundles, so flags applied only to your own sources leave the interesting code untouched.

Which code in a spatial wheel is covered when hardening flags are applied only to your sources A proportional view of compiled code in a typical wheel. The extension module's own sources are a small fraction. GDAL, PROJ, GEOS and the image codecs make up the overwhelming majority, and they are compiled by their own build systems. Applying hardening flags only to your sources therefore covers a small share of the code that actually parses untrusted input; the flags have to be passed into each dependency's build to cover the rest. compiled code in the wheel, by origin your sources GDAL PROJ GEOS codecs covered by CFLAGS set in your build backend covered only if the flags are passed into each dependency's own configure or CMake invocation every driver that parses an untrusted file lives in the orange region, which is where the flags matter most export CFLAGS and CXXFLAGS before building the native stack, and verify with checksec on the bundled .so files, not just the extension

The verification step follows directly: run checksec over every object inside the wheel, not only the extension module. A wheel whose extension is hardened and whose bundled libtiff is not has hardened the code least likely to be handed a hostile file.

Pitfalls & Alternatives

  • Reaching for --security-opt=seccomp=unconfined the moment getrandom is blocked. Disabling seccomp entirely to fix one syscall reopens the whole boundary — a compromised build regains ptrace, mount, and raw socket access. Whitelist the single syscall in a custom profile instead and keep --network=none.
  • Exporting LD_LIBRARY_PATH so 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 with undefined symbol on every other machine. Vendor the library and pin RPATH to $ORIGINLD_LIBRARY_PATH should 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 runtime proj.db lookup. Prefer dynamic linking plus auditwheel repair; reserve static linking for PROJ only, where the data-directory cost is documented in why vendoring PROJ causes wheel bloat.

Further reading: the platform policy enforced by auditwheel is defined by the PyPA manylinux specification (PEP 600).