Skip to content

Security: apet97/anon

Security

SECURITY.md

Security

Threat model (one line)

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.

Secrets policy

  • Runtime configuration lives in the process environment. src/config.ts fails fast at startup if any of PUMBLE_APP_ID, PUMBLE_APP_KEY, PUMBLE_APP_CLIENT_SECRET, or PUMBLE_APP_SIGNING_SECRET is missing.
  • Never commit: .env, .env.* (except .env.example), .pumbleapprc, .pumble-app-manifest.json, tokens.json, conversations.db*, or anything under data/.
  • .pumbleapprc is 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 in src/logger.ts is the last-line defence.
  • Tokens persist in the SQLite tokens table via SqliteCredentialsStore. APP_UNAUTHORIZED deletes the relevant user row; APP_UNINSTALLED deletes every row for the workspace.

Tokens at rest (finding H-2)

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.db file 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/data after the chown node:node step so only the runtime user can read the DB and its WAL sidecars.
    • APP_UNAUTHORIZED and APP_UNINSTALLED both delete the affected tokens rows synchronously so a clean uninstall leaves no residue.
    • The rotation checklist above is the documented incident response for suspected token leakage.
  • What we explicitly do not do yet. Token encryption at rest. Planned before public marketplace listing. Design sketch (formerly AUDIT.md H-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(...) and selectBotTokenStmt.get(...) / selectUserTokenStmt.get(...) so every access_token is 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_KEY is missing and the tokens table 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_PREVIOUS so 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.
  • 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/data volume.

Rotation checklist

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

  1. Revoke current credentials in the Pumble marketplace.

    • Open https://pumble.com/app/marketplace → your app (69950af22720c2992bab57f7 for 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 / botToken become invalid once the reinstall completes.
  2. Clean up any stale local files.

    rm -f tokens.json tokens.json.migrated .pumbleapprc .env

    If you need a working local dev environment, regenerate .env from .env.example with fresh values; never restore the deleted .pumbleapprc.

  3. 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 .env and start the app with npm run start or docker run --env-file .env anon:latest.
  4. Update trigger URLs if the host changed.

    npx pumble-cli pre-publish --host https://<your-production-host>
  5. Verify lifecycle cleanup still fires. Trigger an uninstall and confirm the tokens table row for that workspace is gone (see tests/events/appUninstalled.test.ts for the expected behaviour).

What to do if a secret leaks

  1. Rotate immediately (steps 1–4 above).
  2. Review audit_log for any unexpected SEND, REPLY, or REPORT events from the compromise window and export them if needed.
  3. If tokens.json or application logs were exfiltrated, consider this a confidentiality incident for every workspace currently installed. Notify workspace owners and force reinstall.

Webhook hardening layer (closes S-2..S-5, T-1)

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 /hook route from express._router.stack in onServerConfiguring and replaces it with the hardened pipeline. Verified payloads are dispatched to the SDK service via the same public post* methods (postSlashCommand, postBlockInteractionView, postEvent, etc.) the SDK's own listener uses.
  • src/main.ts — the axios.defaults.timeout = 30_000 for 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 to axios.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)) <= 300 to verifySignature
  • replace the string !== with crypto.timingSafeEqual(Buffer.from(testSignature, 'hex'), Buffer.from(signature, 'hex'))
  • accept an options: { maxBodySize?: number } arg on rawBody() with a sensible default (e.g. 1 MiB)
  • wrap the JSON.parse in a try/catch and short-circuit with 400
  • expose an axios config override on ApiClient's constructor so addons can pass timeout: 30_000

Verified compromised credentials (2026-04-08)

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.

There aren't any published security advisories