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.

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.

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).