Pangolin is the authoritative index of a data lake and holds cloud storage credentials. We take reports seriously and would rather hear about a problem early than read about it later.
Do not open a public issue for a security problem.
Report privately through GitHub's
private vulnerability reporting
on this repository. If that is unavailable to you, email the maintainer listed
in Cargo.toml with PANGOLIN SECURITY in the subject line.
Please include:
- what an attacker can do, and what they need in order to do it (network position, an account, a specific role);
- the affected version or commit;
- a reproduction — a request sequence, a script, or a failing test is ideal;
- your assessment of the impact.
| Stage | Target |
|---|---|
| Acknowledgement of your report | 3 working days |
| Initial assessment and severity | 10 working days |
| Fix released for a critical issue | 30 days from confirmation |
| Public advisory | With the fix, or sooner by agreement |
We will keep you informed while we work, credit you in the advisory unless you prefer otherwise, and tell you before we publish. If we disagree that something is a vulnerability we will explain why rather than going quiet.
Pangolin is pre-1.0 and moves fast. Only the latest minor release receives security fixes.
| Version | Supported |
|---|---|
| 0.8.x | Yes |
| 0.7.x | No — upgrade to 0.8.x |
| 0.6.x and earlier | No — upgrade to 0.8.x, see the advisory below |
Every version before 0.7.0 is affected by a privilege-escalation vulnerability and should not be run. Upgrade to 0.7.0 and rotate every issued token. The findings below come from a full-repo audit conducted the day after the 0.6.0 release; the authorization cluster was outside that release's scope and is new information, not a regression.
Note which version you are actually running. The published container image
alexmerced/pangolin-api was last pushed at 0.5.1 — no 0.6.0 image was
ever released — so most deployments are on 0.5.1, which carries everything
described here and everything 0.6.0 fixed. The affected range is
< 0.7.0, not 0.6.0 alone.
Every issue in this table needs nothing but a valid credential — including the
lowest-privilege tenant-user account, or any service-user API key.
| ID | Issue | Impact |
|---|---|---|
| B0a | POST /api/v1/tokens took no session and mapped a body-supplied roles: ["Root"] straight into signed claims |
Full privilege escalation. Any authenticated caller could mint a valid Root token for any tenant. check_permission short-circuits for Root, so the resulting token bypasses every subsequent authorization check in the system |
| B0b | The credential-vending endpoint performed no authorization, never resolved the table, and hardcoded ["read", "write"] |
Cloud storage credential disclosure. Any tenant member obtained read+write credentials for the entire warehouse, naming a table they had no rights to and which need not exist |
| B0j | Logout revoked session.user_id; revocation is keyed by the token's jti, which no token carries as its user_id |
Logout did nothing. The token stayed valid for its full 24-hour lifetime. On a shared machine, "signing out" left a working credential behind |
| B0g | The Iceberg OAuth token endpoint checked active but not expiry, unlike the API-key path |
Expired credentials were renewable indefinitely. An expired service user could exchange client_credentials for a fresh JWT, repeatedly |
| B0h | The NO_AUTH public-bind guard read no_auth && !dev_mode && !is_loopback(..) |
Unauthenticated tenant-admin access. PANGOLIN_NO_AUTH=true with PANGOLIN_DEV_MODE=true — routinely set together in compose and development setups — started happily on 0.0.0.0 and treated every anonymous request as TenantAdmin |
| B0l | OAuth matched existing users on email with no email_verified check and no provider binding |
Account takeover. Anyone able to set a matching address on any configured provider — GitHub permits unverified addresses — logged in as that Pangolin user, including the seeded tenant admin |
| B0i | PermissionScope::Tenant matched without comparing the grant's tenant to the resource's |
A tenant-wide grant issued in one tenant satisfied authorization for another tenant's resources |
| B0c–B0f | rename_table, update_namespace_properties, view create/read and perform_maintenance performed no authorization check at all |
Any tenant member could move any table (an effective delete), rewrite namespace properties including location, read any view's SQL, and trigger snapshot expiry and orphan-file deletion. perform_maintenance additionally ran against a hardcoded "default" catalog rather than the one addressed |
| B0m | expires_in_hours reached chrono::Duration::hours unclamped, and no catch-panic layer was installed |
Remote panic. A large value aborted the connection task, taking other in-flight requests on that connection with it |
| B0o | A token whose jti was absent or unparseable skipped the revocation check entirely |
Such tokens were unrevocable for their full lifetime |
| ID | Issue | Impact |
|---|---|---|
| — | SqliteStore had no inherent revoke_token/is_token_revoked, so the trait implementations called themselves |
Remote crash on the SQLite backend. Revoking a token — which logout does — recursed until the thread stack was exhausted and aborted the process |
| B2 | On MongoDB the revocation write and the revocation check used different field names and different types | Revocation could never match. Revoked tokens, including after logout, stayed valid |
| ID | Issue | Impact |
|---|---|---|
| B1 | MongoDB's get_audit_event discarded the tenant_id parameter |
Cross-tenant audit disclosure. Any tenant holding an audit-event UUID could read another tenant's record: username, IP address, resource names and metadata |
| — | The SQLite audit_logs table declared different columns than the code inserted |
No audit trail at all on SQLite. Every audit write failed at runtime. If you run SQLite, assume you have no audit history prior to 0.7.0 |
| — | The admin CLI and Python SDK wrote their stored bearer tokens at the process umask, typically 0644 |
Any local account could read the token. Now 0600 under a 0700 directory |
| — | generate-code and get-token printed live JWTs into copy-paste output |
Tokens landed in shell scrollback, session transcripts and pasted snippets |
| B44 | A live PyPI API publish token was committed to the repository working tree in .env |
Never tracked by Git, but readable by any local tool and passed into containers by docker compose. The token has been removed and must be treated as compromised |
Not security boundaries, but silent corruption is worth the same attention:
- B5 MongoDB's
update_metadata_locationignored the compare-and-swap entirely. Two concurrent Iceberg commits both reported success and one snapshot was lost. Memory, Postgres and SQLite all enforced it. - B3 SQLite's
delete_branchreferenced a column that does not exist and was not transactional: the branch was committed away and its assets orphaned permanently, while the caller received an error suggesting nothing happened. - B7 All three persistent backends stored the
Debugspelling ofAssetTypeand parsed only two of seventeen variants. ADeltaTable,MlModelorLanceasset round-tripped as an Iceberg table. - B4 Postgres decoded a
TEXT[]column asString;sqlx::Row::getpanics on a decode failure, so any search returning at least one hit panicked the request.
The aws-sts, azure-oauth and gcp-oauth features - and the
cloud-credentials bundle that unions them - did not compile, at any version.
cargo build and cargo test run with default features and no job ever passed
--features, so the entire cloud-credential surface had rotted into code that
could not be built at all: parameters bound as _name and referenced as name
inside the cfg block, a missing macro import, and an STS expiry parsed from a
DateTime as though it were an RFC3339 string.
This is not an exploitable defect - unbuildable code ships in no binary. It
matters because it means STS-based credential vending was not running
anywhere, so any deployment that believed it was handing out scoped,
time-limited credentials was not. Check what your warehouses are actually
configured with: if use_sts is set but the server was built without the
feature (which is to say, always), the static-credential fallback in
S3Signer::generate_credentials is what answered - vending your long-lived
warehouse keys, with no expiry, instead of a scoped session token. Where no
static keys were configured either, the call failed with "AWS credentials not
configured", which at least failed closed.
Fixed in 0.7.0, with a CI job that builds every optional feature so it cannot recur silently.
- To 0.8.0: docs/upgrading/0.7-to-0.8.md —
MSRV 1.94,
PANGOLIN_ENCRYPTION_KEY, MongoDB uniqueness constraints that can fail on existing duplicates, rate limiting behind a proxy, and OIDC.
- Rotate every issued token. B0a means any account may have minted a
Roottoken, and B0j means logging out never invalidated anything. RotatingPANGOLIN_JWT_SECRETinvalidates all existing sessions at once and is the fastest way to be sure. - Rotate service-user API keys, for the same reason: B0g allowed expired keys to keep issuing fresh JWTs.
- Rotate any cloud storage credentials reachable through a warehouse that an untrusted tenant member could name. B0b vended them to anyone.
- Audit for the escalation.
POST /api/v1/tokenscalls from non-admin principals, and credential-vending calls for tables the caller had no grant on, are the two signals. Note that on SQLite there is no audit history to check, and on MongoDB action-filtered queries returned nothing (B23) — so a clean audit log is not evidence of absence on those backends. - Check
PANGOLIN_NO_AUTHandPANGOLIN_DEV_MODE. If both were set on a non-loopback bind, treat the deployment as having been open to anonymous tenant-admin access for that period. The server now refuses to start in that configuration. - Set
PANGOLIN_OAUTH_EMAIL_LINK_DOMAINSif you rely on OAuth accounts being matched to existing local users by email. Without it, identity is(provider, subject)only and an address never adopts an existing account — which is the safe default, and a behaviour change. - On SQLite, upgrading migrates the
audit_logstable to schema version 2. The previous table is preserved asaudit_logs_pre_v2; it is empty in practice, because nothing could ever write to it. - Third-party API clients may need changes. Server request bodies now
reject unknown fields with
422instead of silently ignoring them. If a client sent a misspelled or obsolete field, it was already being discarded — the request was never doing what it appeared to.
0.6.0 is a security release. If you run anything earlier, upgrade and rotate credentials. The following were exploitable:
| ID | Issue | Impact |
|---|---|---|
| A-8 | The OAuth callback appended the session JWT to a redirect URL taken from the unsigned state parameter, with no allowlist |
Account takeover. An authorize link whose state decoded to {"redirect_uri":"https://evil.example/"} delivered a valid token for the victim to the attacker's access log, with no credential theft required |
| A-9 | The OAuth state nonce was generated but never stored or verified; state was not validated at all |
Login CSRF. An attacker could bind a victim's browser to the attacker's account |
| A-10 | PANGOLIN_JWT_SECRET fell back to default_secret_for_dev, a value published in this repository; the Helm chart shipped change-me-please and password as working defaults, and the seeded admin used password123 |
Anyone could forge a Root token against a deployment that missed one environment variable |
| A-11 | The authentication whitelist matched any path ending in /config and any path containing /oauth/tokens |
Authentication bypass. A namespace or table named config was reachable unauthenticated, including its DELETE route |
| A-12 | API-key authentication ran bcrypt::verify against every service user in every tenant |
Unauthenticated denial of service. At 100 service users a single request with a bogus key burned ~25 CPU-seconds, before any rate limiting |
| A-13 | A store error during the token-revocation check was logged and ignored | Revoked tokens were accepted again during any database disruption |
| A-14 | The root password was compared with == |
Timing oracle on the password |
Correctness defects fixed in the same release — silent data loss rather than a security boundary, but worth knowing about if you ran concurrent writers:
- A-1
assert-ref-snapshot-idwas never enforced, so a writer whose compare-and-swap lost would retry and re-apply its snapshot onto a branch that had moved on: forked snapshot lineage and orphaned data files, with no error ever surfaced. - A-2 Eleven commit update types were discarded while returning
200 OK. - A-3
last_sequence_numberwas assigned a snapshot ID, which can produce incorrect query results on merge-on-read tables.
- Set
PANGOLIN_JWT_SECRETto a real secret (openssl rand -base64 48). The server now refuses to start without one, and rejects known placeholders. Changing it invalidates every existing session — which you want, because any token issued under the old default was forgeable. - Rotate service-user API keys. Keys are now issued in a
pgl_<key-id>_<secret>format that makes lookup O(1). Pre-existing keys keep working only if you setPANGOLIN_ALLOW_LEGACY_API_KEYS=true, which reintroduces the scan and should be temporary. - Set
PANGOLIN_OAUTH_REDIRECT_URISif you use OAuth. Redirect targets are now allowlisted, and the token is delivered by a one-time code exchange rather than a URL parameter — seedocs/operations/oidc.mdfor the client change. - Review your audit log for
tenant_impersonation,login_failedandapi_key_rejectedevents, which are recorded from 0.6.0 onward.
Before exposing Pangolin to anything you care about:
-
PANGOLIN_JWT_SECRETis at least 32 bytes of real entropy and is not in Git. PreferexistingSecretin the Helm chart overvalues.yaml. -
PANGOLIN_NO_AUTHis unset. It grants tenant-admin to anonymous callers; the server refuses to start with it enabled on a non-loopback address, but do not rely on that. -
PANGOLIN_DEV_MODEis unset. -
PANGOLIN_ROOT_USERis unset unless you actively need root basic auth. - TLS is terminated in front of the server. Pangolin binds plain HTTP.
-
PANGOLIN_CORS_ALLOWED_ORIGINSis set. The default allows any origin, which is only safe behind a trusted gateway. -
PANGOLIN_ALLOW_LEGACY_API_KEYSis unset once keys are rotated. - Containers run as non-root with a read-only root filesystem — the shipped chart defaults do this; confirm you have not overridden them.
- Database backups are encrypted. Warehouse rows contain cloud storage credentials in the clear; envelope encryption at rest is not yet implemented (C-11).
-
/metricsis not exposed publicly. It is unauthenticated by design for scraping; setPANGOLIN_METRICS_ENABLED=falseif you cannot restrict it.
The full reconciled list, including non-security work, is in STATUS.md.
Stated plainly, because a checklist that hides its limits is worse than none:
- Rate limiting is in-process, so it is per replica. The authentication endpoints are throttled from 0.7.0, per source address and per account, but the counters are not shared between replicas: with N replicas an attacker gets N times the configured budget. A shared limiter needs Redis or equivalent, which this project does not otherwise require.
- OIDC applies only to providers that support it. Google, Microsoft, Okta
and any IdP configured via
PANGOLIN_<PROVIDER>_ISSUERget PKCE,id_tokensignature validation via JWKS, andiss/aud/exp/noncechecks. GitHub issues noid_token, so its logins still rest on the userinfo endpoint; setPANGOLIN_OIDC_REQUIRE=trueto refuse any provider that cannot be OIDC-validated. No back-channel logout, no refresh-token handling, and the PKCE verifier is held in process so OAuth needs session affinity. - Symmetric HS256 JWTs with no key rotation. Rotating the secret invalidates every session at once.
- Warehouse credentials are encrypted at rest only when a key is set.
PANGOLIN_ENCRYPTION_KEYenables AES-256-GCM sealing of the credential fields; unset, they are plaintext and the server warns at startup. The key lives in the server environment, so this protects a stolen database and not a compromised host. - Audit records are not tamper-evident. They live in the same database as application data, with no hash chaining and no WORM option.
- No MFA, password policy, or account lockout.
- Eight dependency advisories are accepted rather than fixed, each with its
reasoning recorded in
.cargo/audit.toml. None is reachable in a default build, but they are exceptions rather than absences:rsa(never compiled),quick-xml(held back byobject_store0.11 andazure_core0.20), a secondrustls-webpkiarriving via the AWS SDK'srustls0.21,http-types, andrand's custom-logger unsoundness. Re-check the list when dependencies move.
These are tracked in AUDIT_EXECUTION_PLAN.md (items C-2 through C-20).