Recipients must not learn the sender's identity; workspace admins must, on report, learn the sender's identity. Everyone else — including anyone with access to application logs — must see neither the sender identity for a given message nor the raw message body.
- Runtime configuration lives in the process environment.
src/config.tsfails fast at startup if any ofPUMBLE_APP_ID,PUMBLE_APP_KEY,PUMBLE_APP_CLIENT_SECRET, orPUMBLE_APP_SIGNING_SECRETis missing. - Never commit:
.env,.env.*(except.env.example),.pumbleapprc,.pumble-app-manifest.json,tokens.json,conversations.db*, or anything underdata/. .pumbleapprcis a CLI convenience, not a secret source. Do not use it in production. The CI pipeline and container images must source credentials from env vars or a managed secret store.- Logs never contain raw message bodies. Handlers log only
workspaceId,userId,convId,eventType, and outcome. The pino redaction list insrc/logger.tsis the last-line defence. - Tokens persist in the SQLite
tokenstable viaSqliteCredentialsStore.APP_UNAUTHORIZEDdeletes the relevant user row;APP_UNINSTALLEDdeletes every row for the workspace.
Bot JWTs and user JWTs are stored as plain TEXT in the tokens table. This
is a deliberate, documented trade-off for the current deployment shape:
- Threat. If the
/app/data/anon.dbfile is exfiltrated (backup copied, host compromised, mis-configured container mount), every stored token is immediately reusable against Pumble's API until the operator rotates. - What we do today.
- The Dockerfile runs
chmod 700 /app/dataafter thechown node:nodestep so only the runtime user can read the DB and its WAL sidecars. APP_UNAUTHORIZEDandAPP_UNINSTALLEDboth delete the affectedtokensrows synchronously so a clean uninstall leaves no residue.- The rotation checklist above is the documented incident response for suspected token leakage.
- The Dockerfile runs
- What we explicitly do not do yet. Token encryption at rest.
Planned before public marketplace listing. Design sketch
(formerly
AUDIT.mdH-2 Option B, inlined here so this document is self-contained):- Add a required env var
TOKEN_ENCRYPTION_KEY— 32 random bytes, base64-encoded (openssl rand -base64 32). - Wrap
SqliteCredentialsStore.upsertStmt.run(...)andselectBotTokenStmt.get(...)/selectUserTokenStmt.get(...)so everyaccess_tokenis sealed at write-time with AES-256-GCM (12-byte IV per row, 16-byte tag, stored alongside the ciphertext) and unsealed at read-time. - On startup, refuse to boot if
TOKEN_ENCRYPTION_KEYis missing and thetokenstable already contains rows whose layout looks plaintext — operators must explicitly run a one-shot migration that re-wraps every row. - Rotation: support an optional
TOKEN_ENCRYPTION_KEY_PREVIOUSso a key rotation can decrypt-with-old, re-encrypt-with-new in a single background scan without downtime. - Threat model: this defends against database-only exfiltration; an
attacker who reads the running process memory still observes the
cleartext token in transit between the store and
axiosInstance.
- Add a required env var
- Operational rules.
- Never copy
/app/data/anon.db*off the host for anything other than an operator-initiated backup, and never to an untrusted location. - If the host is compromised, treat every token as leaked: rotate the App Key, OAuth Client Secret, and Signing Secret via the Pumble marketplace (steps 1–4 above), then force a reinstall in every workspace.
- CI pipelines and container images must never receive the production
/app/datavolume.
- Never copy
Use this whenever credentials may have been exposed (for example, if
tokens.json or .pumbleapprc ever sat on an untrusted machine, if a
contributor's laptop was lost, or on a routine schedule).
-
Revoke current credentials in the Pumble marketplace.
- Open https://pumble.com/app/marketplace → your app (
69950af22720c2992bab57f7for the dev workspace import) → Credentials. - Regenerate the App Key, OAuth Client Secret, and Signing Secret.
- If the workspace already installed the app, uninstall and reinstall to
force fresh OAuth consent. The old
accessToken/botTokenbecome invalid once the reinstall completes.
- Open https://pumble.com/app/marketplace → your app (
-
Clean up any stale local files.
rm -f tokens.json tokens.json.migrated .pumbleapprc .env
If you need a working local dev environment, regenerate
.envfrom.env.examplewith fresh values; never restore the deleted.pumbleapprc. -
Populate production secrets.
- In production, provide the rotated values as environment variables or via your platform's secret store (Kubernetes Secret, Railway/Fly env, etc.).
- For local verification, populate
.envand start the app withnpm run startordocker run --env-file .env anon:latest.
-
Update trigger URLs if the host changed.
npx pumble-cli pre-publish --host https://<your-production-host>
-
Verify lifecycle cleanup still fires. Trigger an uninstall and confirm the
tokenstable row for that workspace is gone (seetests/events/appUninstalled.test.tsfor the expected behaviour).
- Rotate immediately (steps 1–4 above).
- Review
audit_logfor any unexpectedSEND,REPLY, orREPORTevents from the compromise window and export them if needed. - If tokens.json or application logs were exfiltrated, consider this a confidentiality incident for every workspace currently installed. Notify workspace owners and force reinstall.
The 2026-05-24 adversarial review
(docs/reviews/2026-05-24-adversarial-review.md) identified five
defences missing from pumble-sdk's default middleware
(pumble-sdk/lib/core/adapters/http/middlewares.js) and its
axios.create() call site. These are now closed inside the addon —
no operator action required.
| ID | Default SDK behaviour | Closed by |
|---|---|---|
| S-2 | No x-pumble-request-timestamp staleness check; captured requests are replayable indefinitely. |
hardenWebhook rejects any timestamp outside ±5 minutes of wall-clock with 403. |
| S-3 | HMAC compared via plain string !==; timing-attack viable in theory. |
hardenWebhook compares via crypto.timingSafeEqual with explicit buffer-length check first. |
| S-4 | rawBody() concatenates incoming chunks unbounded; a malicious sender can drive the Node process to OOM. |
hardenWebhook enforces a 1 MiB cap (configurable) and rejects with 413 + Connection: close before any HMAC work. |
| S-5 | JSON.parse(rawBody) is unguarded; a malformed body throws synchronously and is caught only by the addon's uncaughtException handler. |
hardenWebhook parses inside try/catch and returns 400; the process keeps running. |
| T-1 | axios.create({ baseURL, headers }) ships with no timeout, so a hanging Pumble response would block the handler forever. |
src/main.ts sets axios.defaults.timeout = 30_000 before the SDK imports; all SDK-created axios instances inherit the default. |
The hardening lives in:
src/http/hardenedWebhook.ts— Express middleware factory that removes the SDK's/hookroute fromexpress._router.stackinonServerConfiguringand replaces it with the hardened pipeline. Verified payloads are dispatched to the SDK service via the same publicpost*methods (postSlashCommand,postBlockInteractionView,postEvent, etc.) the SDK's own listener uses.src/main.ts— theaxios.defaults.timeout = 30_000for T-1.tests/security/hardenedWebhook.test.ts— 26-case suite covering every signature failure mode, body-cap, malformed-JSON, and exhaustive dispatcher branch.tests/security/axiosTimeout.test.ts— pins the axios contract the T-1 fix depends on (defaults propagate toaxios.create).
The one private API touch is express._router.stack — a stable
Express 4.x field used by many auth and observability libraries.
It is isolated to removeSdkWebhookRoute() in
src/http/hardenedWebhook.ts; an SDK upgrade that changes mount
order can be accommodated by adjusting that single function.
Upstream PRs against
CAKE-com/pumble-node-sdk
are still worth filing so other Pumble apps get the same defences
by default. The addon's local hardening is not a substitute for an
upstream fix — it is what closes the gap for this deployment until
one lands. Suggested upstream changes:
- add
Math.abs(nowSec - parseInt(timestamp, 10)) <= 300toverifySignature - replace the string
!==withcrypto.timingSafeEqual(Buffer.from(testSignature, 'hex'), Buffer.from(signature, 'hex')) - accept an
options: { maxBodySize?: number }arg onrawBody()with a sensible default (e.g. 1 MiB) - wrap the
JSON.parsein a try/catch and short-circuit with 400 - expose an
axiosconfig override onApiClient's constructor so addons can passtimeout: 30_000
The abot/ prototype directory contains tokens.json and .pumbleapprc
with live credentials for workspace 64ad1305c701cc5be7c26fe4 and app
69950af22720c2992bab57f7. These must be rotated using the checklist
above before the standalone repo is used in production. The rotation is a
manual post-session step.