{"schema":"threatline.record/2","record":{"id":"GHSA-wfrw-rm3f-52x8","aliases":["GHSA-wfrw-rm3f-52x8"],"summary":"`notification.rules` returns integration objects with plaintext Slack bot tokens / webhook URLs / auth headers to read-level project members, bypassing the redact-on-read invariant that `integration.get` and `integration.list` enforce","details":"## Summary\r\nOpenpanel stores integration secrets (Slack `access_token`, `incoming_webhook.url`, and webhook auth\r\n`headers`) in **plaintext** and relies on a **redact-on-read** invariant: `integration.get` and\r\n`integration.list` both call `redactIntegration` before returning. The `notification.rules` query does\r\nnot. It is a `protectedProcedure` gated by `enforceAccess` at only the **read** level (bare project\r\nmembership), yet it eager-loads `integrations` and spreads them **raw** into the response — so any\r\nread-level member receives the plaintext credentials of every integration attached to the project's\r\nnotification rules, even though creating/editing integrations requires a higher (write/admin) privilege.\r\n\r\n## Details\r\n`packages/trpc/src/routers/notification.ts` — `rules`:\r\n```ts\r\n// protectedProcedure; enforceAccess requires only level:'read' for the projectId input\r\nconst rules = await db.notificationRule.findMany({\r\n  where: { projectId },\r\n  include: { integrations: true },            // :52  eager-loads integration rows (with secrets)\r\n});\r\nreturn rules.map(rule => ({\r\n  ...rule,\r\n  integrations: rule.integrations,            // :71  spread RAW — no redactIntegration()\r\n}));\r\n```\r\n`packages/integrations/src/registry.ts`: the Slack `access_token`, `incoming_webhook.url`, and webhook\r\n`headers` are declared as secret fields **without `encrypted: true`** (~:119-131,161), so they are stored\r\nplaintext; only `redactConfigSecrets` blanks them, and only on the read paths that call it. **Contrast:**\r\n`integration.get` (`integration.ts:197`) and `integration.list` (`:223`) both call `redactIntegration`;\r\n`notification.rules` and `integration.delete` do not.\r\n\r\n`enforceAccess` (`packages/trpc/src/trpc.ts:99-110`) auto-derives the required level from the input; for\r\n`rules` the top-level `projectId` maps to **read**. Creating/updating integrations goes through\r\n`upsertIntegration` + `assertIntegrationAccess` (write/admin), so a read-level member **cannot** set these\r\nsecrets — but can read them here. That is the privilege boundary being crossed.\r\n\r\n## Impact\r\nAny read-level member of a project (e.g. a low-trust analyst invited to view dashboards) can call\r\n`notification.rules` and obtain the plaintext Slack bot token, webhook URLs, and webhook authorization\r\nheaders configured by an admin. Those are live credentials to the org's external systems (post to their\r\nSlack, hit authenticated webhooks), usable outside Openpanel. Secondary: `integration.delete`\r\n(`integration.ts:354`) also returns the unredacted row, though it is write/admin-gated (lower severity).\r\n\r\n## Proof of concept (benign)\r\n1. As a project admin, add a Slack (or webhook) integration with a marker credential (e.g. webhook header\r\n   `Authorization: Bearer hackchang-poc-marker`) and attach it to a notification rule.\r\n2. Invite a user as a **read-level** member of that project (a role that cannot create/edit integrations —\r\n   verify `integration.upsert` is denied for them).\r\n3. As that read-level member, call the `notification.rules` tRPC query for the project and inspect\r\n   `integrations[]` — the marker credential is returned in plaintext (it should be redacted, as\r\n   `integration.get`/`list` do). (Use marker credentials you control.)\r\n\r\n## Remediation\r\nApply `redactIntegration` to every integration object returned by `notification.rules` (and to the row\r\nreturned by `integration.delete`), matching `integration.get`/`list`. Better, encrypt the secret fields at\r\nrest (`encrypted: true`) so a missed redact does not expose plaintext, and gate integration reads on the\r\nsame privilege as integration writes.\r\n\r\n## Novelty / dedup\r\nNo published openpanel advisory covers this credential leak. Distinct from **GHSA-f9rx-pxgw-c6rg**\r\n(missing write-gate on report/dashboard mutations). Novel.\r\n\r\n## Disclosure\r\nReported privately via GitHub Private Vulnerability Reporting by **@hackchang**. Please coordinate a fix\r\nunder a 90-day window, and publish + request a CVE when the fix ships; happy to re-test.\r\n","categories":["Unclassified"],"product":"openpanel","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-wfrw-rm3f-52x8","source_modified_at":"2026-10-11T21:07:03Z","fetched_at":"2026-10-28T01:10:53.534015Z","package":{"ecosystem":"openpanel-dev","name":"openpanel"},"range":"<= 35ddc8ebbcd4ed0767933de61f014f153d68442a"}],"fixed_versions":[],"cvss":8.5,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-11T21:07:03Z","modified_at":"2026-10-11T21:07:03Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-wfrw-rm3f-52x8","fetched_at":"2026-10-28T01:10:53.534015Z","source_modified_at":"2026-10-11T21:07:03Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-11T21:07:03Z","content_hash":"7266c683010248ee246f18758555b9ab7b720266f9dab5e7ceca19952988a386"}}
