
PoC — missing authorization on the platform-wide GPG trust-anchor store in Terrapod (GHSA-6qrc-597p-mrp9, CVE-2026-87006, CVSS 6.5).
CVE status: requested, pending assignment. This finding is published as GHSA-6qrc-597p-mrp9. On CVE assignment this repository is renamed
CVE-YYYY-NNNNN-terrapod-PoCand this banner is replaced with the CVE link.
| Researcher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-6qrc-597p-mrp9 |
| CVSS 3.1 | 6.5 (Medium) |
| Weakness | CWE-862, CWE-284 |
Summary
Missing Authorization in the GPG-key management API in Terrapod (main @ b36d953, post-v1.3.1) allows any authenticated principal — including a user with only the built-in everyone role and zero capability grants, or a scoped runner token — to create and delete entries in the platform-wide GPG trust-anchor store used to verify signatures on every provider published to the private registry, via POST /api/terrapod/v1/gpg-keys and DELETE /api/terrapod/v1/gpg-keys/{key_id}.
Product
Terrapod (mattrobinsonsre/terrapod) — self-hosted Terraform Enterprise / HCP Terraform replacement.
Tested Version
Commit b36d9535dedc31d85a02093d78f04da748492a2d (main), 10 commits ahead of the closest release tag v1.3.1. (v1.3.2 exists as a tag but sits on the release/v1.3 branch and is not yet merged to main; the bug is present on both.)
Estimated CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N — 6.4 (Medium)
PR:L (not N): every request still needs a valid credential — a session, an API token, or even a run-scoped runtok: runner token — just not a privileged one. I:H/C:N/A:N: the bug lets an unprivileged caller corrupt the integrity of the registry's shared signing-key trust store (delete legitimate keys, insert their own), but doesn't itself disclose secret key material (private keys are never serialized in any response) or take the service offline.
Details
services/terrapod/api/routers/gpg_keys.py implements CRUD for GPGKey rows — the ASCII-armored public keys Terrapod trusts to verify the detached SHA256SUMS.sig on every provider version published to the private registry (services/terrapod/services/registry_provider_service.py:191-201, _verify_and_store_shasums_signature). Every route in the router is gated only by Depends(get_current_user) — i.e. "some authenticated principal" — with no role or capability check at all:
create_gpg_key_endpoint — services/terrapod/api/routers/gpg_keys.py:104-130list_gpg_keys_endpoint — gpg_keys.py:133-146show_gpg_key_endpoint — gpg_keys.py:149-166revoke_gpg_key_endpoint — gpg_keys.py:168-193delete_gpg_key_endpoint — gpg_keys.py:195-213The underlying service functions (services/terrapod/services/gpg_key_service.py:144 create_gpg_key, :316 delete_gpg_key) take no caller/ownership argument at all — GPGKey has no namespace or owner column (_gpg_key_to_jsonapi hardcodes "namespace": "default"; the namespace field accepted on create is parsed by the request model but never passed through). The key set is one global, shared list for the whole platform.
Contrast this with every other admin-owned platform resource in the same codebase — tokens.py, roles.py, vcs_connections.py, role_assignments.py — which all gate mutating routes behind require_admin or an explicit bound_to == user.email or is_admin ownership check. gpg_keys.py is the one router in api/routers/ that manages a platform-wide security-critical resource with none of that.
Impact chain: get_gpg_key_by_key_id() (registry_provider_service.py:195) does a global, unscoped lookup — any key ever registered, by anyone, is a valid trust anchor for any provider publish (this is by design for self-service publisher key registration — the error message even says add it via /api/terrapod/v1/gpg-keys first). Because registration has no auth check, and deletion has no auth check either:
require_non_runner exists specifically to keep off "resource creation and management endpoints" per its own docstring in services/terrapod/api/dependencies.py:387-400, but which this router never uses) can delete any other tenant's registered signing key, breaking signature verification for every provider version that tenant already published — a cross-tenant integrity attack with zero relationship to the target namespace required.The project's own test suite documents this gap without questioning it — services/tests/api/test_gpg_keys.py builds its create/delete/revoke happy-path tests with AuthenticatedUser(roles=["everyone"], ...) (see _user(), line 23, used throughout TestCreate/TestDelete/TestRevoke) and asserts 201/204 for that unprivileged user — i.e. the tests assert the vulnerable behavior as correct, they just were never written to ask "should everyone be allowed to do this?"
Proof of Concept
Dynamically verified against the real application (real Postgres + Redis via the project's own docker-compose.test.yml integration-test harness — no mocks, no unrelated modifications to app code):
services/tests/integration/test_gpg_key_missing_authz_poc.py:
test_everyone_role_user_can_delete_admins_signing_key — an admin registers a real RSA-2048 PGP public key via POST /api/terrapod/v1/gpg-keys (201, confirmed present in Postgres), then a second user authenticated with roles=["everyone"] only (no admin, no capability grants) sends DELETE /api/terrapod/v1/gpg-keys/{key_id} and it succeeds (204); the row is confirmed gone from Postgres afterward.test_everyone_role_user_can_register_new_trusted_key — the same unprivileged user registers a brand-new key via POST /api/terrapod/v1/gpg-keys (201).docker build -f docker/Dockerfile.test -t terrapod-test:local .
docker compose -f docker-compose.test.yml run --rm test \
pytest tests/integration/test_gpg_key_missing_authz_poc.py -v -m integration
2 passed — both assertions (the unauthorized delete succeeding, and the row actually disappearing from the real database) held. Screenshot: .Impact
Any authenticated user of a Terrapod instance — regardless of role, workspace access, or registry permissions, down to the built-in unprivileged everyone role or a single run's scoped runner token — can tamper with the platform's shared GPG trust-anchor store: delete another team's registered signing key (breaking terraform init signature verification for every provider version they already published, platform-wide) and/or add new keys to the trusted set. This undermines the "GPG-signed private module + provider registry" supply-chain guarantee the project advertises as a headline feature, without requiring any workspace or registry privilege on the attacker's part.
Weaknesses
Remediation
Add a capability/role gate to the mutating routes in services/terrapod/api/routers/gpg_keys.py, matching the pattern already used by tokens.py/roles.py/vcs_connections.py — e.g. Depends(require_admin) on create_gpg_key_endpoint, delete_gpg_key_endpoint, and revoke_gpg_key_endpoint (revoke is separately protected by requiring a valid self-revocation certificate, but should still not be reachable by an arbitrary caller for other tenants' keys). list/show are lower risk (the armor blocks are public keys by design) but should probably also require at least require_non_runner for consistency. Consider also introducing a namespace/owner column on GPGKey if self-service per-namespace publisher keys are the intended model, so a namespace owner can manage only their own key(s) rather than the single global list.
Credit: Dostxodjayev Abdullox
Reporting Channel: Per SECURITY.md, do not open a public issue. Use GitHub's private vulnerability reporting: go to https://github.com/mattrobinsonsre/terrapod/security/advisories/new, click "Report a vulnerability", and fill in the description, steps to reproduce, and affected versions. (If PVR is unavailable, the policy says to email the maintainer directly.)
evidence/gpg_key_authz_poc_run3.png