GHSA-W4MQ-XH27-6XPX

Vulnerability from github – Published: 2026-08-21 19:14 – Updated: 2026-08-21 19:14
VLAI
Summary
Unleash: Global Mustache.escape override disables HTML escaping process-wide, enabling Slack/Teams link-injection via unrestricted username
Details

Vulnerability Details

File: src/lib/addons/feature-event-formatter-md.ts Line: 355 (in v8.0.1; format() method)

Root Cause

FeatureEventFormatterMd.format() does:

Mustache.escape = (text) => text;
const text = Mustache.render(action, context);

mustache (pinned ^4.2.0, confirmed installed 4.2.0) keeps escape as a module-level singleton (mustache.js: mustache.escape = escapeHtml;), read by every Mustache.render() call in the process unless a per-call config.escape override is passed (var escape = this.getConfigEscape(config) || mustache.escape;). Node's module cache guarantees every import Mustache from 'mustache' in the process — feature-event-formatter-md.ts, email-service.ts, webhook.ts, datadog.ts, new-relic.ts — shares the same object instance.

This assignment therefore permanently disables HTML escaping for every other Mustache.render() call in the same Node process (including email-service.ts templates) from the moment any single notification addon (Webhook, Slack legacy, Microsoft Teams, Datadog, New Relic) first formats any event, for the remaining lifetime of the process.

feature-event-formatter-md-events.ts (EVENT_MAP) confirms the blast radius: nearly every event's action template interpolates attacker-controlled values with single-mustache (intended-to-be-escaped) syntax, most importantly {{user}}, which is event.createdBy — the acting account's username (or email if set; src/lib/util/extract-user.ts: extractUsernameFromUser). Neither username nor name have any charset/length validation anywhere in the codebase (create-user-schema.ts, create-invited-user-schema.ts, user-service.ts:289 only does Joi.assert(name, Joi.string(), 'Name') — a type check, nothing more).

Slack's own API docs require &, <, > to be replaced with &amp;, &lt;, &gt; before sending user-generated text, specifically so Slack's mrkdwn parser does not interpret it as <url|label> link syntax. Mustache's default escapeHtml happens to produce exactly those entities, so this was (likely unintentionally) the application's only defense against link-injection in chat notifications — and it is unconditionally switched off by the same code path that depends on it.

Attack Scenario

  1. Admin has a Webhook, Slack (legacy), Microsoft Teams, Datadog, or New Relic integration configured (a very common production setup for flag-change notifications).
  2. Attacker has (or self-registers, if public signup is enabled — POST /invite/:token/signup is permission: NONE) any Editor-level account and sets username to e.g. evil<https://attacker.example/urgent-rollback|Click here to view incident>.
  3. Attacker performs any ordinary write action (create/update/toggle a feature flag — routine, no special privilege beyond Editor on one project).
  4. The configured addon's handleEvent() calls this.msgFormatter.format(event), which mutates the global escape function and immediately renders the {{user}}-containing template with escaping disabled.
  5. The resulting message — containing the attacker's raw <url|label> Slack link syntax — is POSTed to the team's Slack/Teams channel or webhook endpoint and rendered as a real, clickable, attacker-labeled hyperlink inside a trusted automated notification feed.

Vulnerable Code

Mustache.escape = (text) => text;

const text = Mustache.render(action, context);
const url = path
    ? `${this.unleashUrl}${Mustache.render(path, context)}`
    : undefined;

Impact

  • Stored markdown/link-injection (phishing-link injection) into any configured outbound notification channel (Slack legacy, MS Teams, Webhook default markdown, Datadog, New Relic), using an attacker-controlled username — no admin privilege required, only Editor on a single project, and potentially reachable through public self-signup.
  • Secondary: loss of HTML escaping for any other reachable Mustache single-mustache placeholder process-wide until restart (increases severity of any other currently-unreached or future Mustache sink, e.g. email templates).
  • Tertiary: a custom Webhook bodyTemplate that interpolates raw event/user fields directly into a JSON string literal (rather than the pre-escaped eventJson field the code already provides for this purpose) can have its JSON structure broken by an attacker-controlled " character once the global escape function is neutered.

Recommended Fix

Never mutate the shared Mustache.escape global. Pass a local escape function via Mustache's per-call render option instead (supported and typed in @types/mustache@4.2.6's RenderOptions.escape):

const renderConfig = { escape: (text: string) => text };
const text = Mustache.render(action, context, undefined, renderConfig);
const url = path
    ? `${this.unleashUrl}${Mustache.render(path, context, undefined, renderConfig)}`
    : undefined;

Verification

Dynamically confirmed on v8.0.1 in a local Docker lab (official unleashorg/unleash-server:8.0.1 image + Postgres 15): - Created a Webhook addon with the addon UI's own placeholder bodyTemplate ({{event.createdBy}} etc.), pointed at a local listener. - Created an Editor-role user with username = evil2<https://attacker.example/urgent-rollback|Click here to view incident> (accepted with HTTP 201, no sanitization). - Logged in as that user and created a feature flag (ordinary Editor action). - Captured webhook payload: "createdBy": "evil2<https://attacker.example/urgent-rollback|Click here to view incident>"<, >, | completely unescaped, live Slack link-injection syntax. - Control test with the same pinned mustache@4.2.0 package confirmed the default (pre-bug) output for the same string would have been evil2&lt;https:&#x2F;&#x2F;attacker.example&#x2F;urgent-rollback|Click here to view incident&gt; — i.e. the single global assignment is solely responsible for the unescaped output observed live.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "unleash-server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "8.0.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-63466"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-116"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-21T19:14:52Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Vulnerability Details\n\n**File**: `src/lib/addons/feature-event-formatter-md.ts`\n**Line**: 355 (in v8.0.1; `format()` method)\n\n### Root Cause\n\n`FeatureEventFormatterMd.format()` does:\n\n```ts\nMustache.escape = (text) =\u003e text;\nconst text = Mustache.render(action, context);\n```\n\n`mustache` (pinned `^4.2.0`, confirmed installed `4.2.0`) keeps `escape` as a **module-level singleton** (`mustache.js`: `mustache.escape = escapeHtml;`), read by every `Mustache.render()` call in the process unless a per-call `config.escape` override is passed (`var escape = this.getConfigEscape(config) || mustache.escape;`). Node\u0027s module cache guarantees every `import Mustache from \u0027mustache\u0027` in the process \u2014 `feature-event-formatter-md.ts`, `email-service.ts`, `webhook.ts`, `datadog.ts`, `new-relic.ts` \u2014 shares the same object instance.\n\nThis assignment therefore **permanently disables HTML escaping for every other `Mustache.render()` call in the same Node process** (including `email-service.ts` templates) from the moment any single notification addon (Webhook, Slack legacy, Microsoft Teams, Datadog, New Relic) first formats any event, for the remaining lifetime of the process.\n\n`feature-event-formatter-md-events.ts` (`EVENT_MAP`) confirms the blast radius: nearly every event\u0027s `action` template interpolates attacker-controlled values with single-mustache (intended-to-be-escaped) syntax, most importantly `{{user}}`, which is `event.createdBy` \u2014 the acting account\u0027s `username` (or `email` if set; `src/lib/util/extract-user.ts`: `extractUsernameFromUser`). Neither `username` nor `name` have any charset/length validation anywhere in the codebase (`create-user-schema.ts`, `create-invited-user-schema.ts`, `user-service.ts:289` only does `Joi.assert(name, Joi.string(), \u0027Name\u0027)` \u2014 a type check, nothing more).\n\nSlack\u0027s own API docs require `\u0026`, `\u003c`, `\u003e` to be replaced with `\u0026amp;`, `\u0026lt;`, `\u0026gt;` before sending user-generated text, specifically so Slack\u0027s mrkdwn parser does not interpret it as `\u003curl|label\u003e` link syntax. Mustache\u0027s default `escapeHtml` happens to produce exactly those entities, so this was (likely unintentionally) the application\u0027s only defense against link-injection in chat notifications \u2014 and it is unconditionally switched off by the same code path that depends on it.\n\n### Attack Scenario\n1. Admin has a Webhook, Slack (legacy), Microsoft Teams, Datadog, or New Relic integration configured (a very common production setup for flag-change notifications).\n2. Attacker has (or self-registers, if public signup is enabled \u2014 `POST /invite/:token/signup` is `permission: NONE`) any **Editor**-level account and sets `username` to e.g. `evil\u003chttps://attacker.example/urgent-rollback|Click here to view incident\u003e`.\n3. Attacker performs any ordinary write action (create/update/toggle a feature flag \u2014 routine, no special privilege beyond Editor on one project).\n4. The configured addon\u0027s `handleEvent()` calls `this.msgFormatter.format(event)`, which mutates the global escape function and immediately renders the `{{user}}`-containing template with escaping disabled.\n5. The resulting message \u2014 containing the attacker\u0027s raw `\u003curl|label\u003e` Slack link syntax \u2014 is POSTed to the team\u0027s Slack/Teams channel or webhook endpoint and rendered as a real, clickable, attacker-labeled hyperlink inside a trusted automated notification feed.\n\n### Vulnerable Code\n```ts\nMustache.escape = (text) =\u003e text;\n\nconst text = Mustache.render(action, context);\nconst url = path\n    ? `${this.unleashUrl}${Mustache.render(path, context)}`\n    : undefined;\n```\n\n### Impact\n- Stored markdown/link-injection (phishing-link injection) into any configured outbound notification channel (Slack legacy, MS Teams, Webhook default markdown, Datadog, New Relic), using an attacker-controlled username \u2014 no admin privilege required, only Editor on a single project, and potentially reachable through public self-signup.\n- Secondary: loss of HTML escaping for any other reachable Mustache single-mustache placeholder process-wide until restart (increases severity of any other currently-unreached or future Mustache sink, e.g. email templates).\n- Tertiary: a custom Webhook `bodyTemplate` that interpolates raw event/user fields directly into a JSON string literal (rather than the pre-escaped `eventJson` field the code already provides for this purpose) can have its JSON structure broken by an attacker-controlled `\"` character once the global escape function is neutered.\n\n### Recommended Fix\nNever mutate the shared `Mustache.escape` global. Pass a local escape function via Mustache\u0027s per-call render option instead (supported and typed in `@types/mustache@4.2.6`\u0027s `RenderOptions.escape`):\n\n```ts\nconst renderConfig = { escape: (text: string) =\u003e text };\nconst text = Mustache.render(action, context, undefined, renderConfig);\nconst url = path\n    ? `${this.unleashUrl}${Mustache.render(path, context, undefined, renderConfig)}`\n    : undefined;\n```\n\n### Verification\nDynamically confirmed on v8.0.1 in a local Docker lab (official `unleashorg/unleash-server:8.0.1` image + Postgres 15):\n- Created a Webhook addon with the addon UI\u0027s own placeholder `bodyTemplate` (`{{event.createdBy}}` etc.), pointed at a local listener.\n- Created an Editor-role user with `username = evil2\u003chttps://attacker.example/urgent-rollback|Click here to view incident\u003e` (accepted with HTTP 201, no sanitization).\n- Logged in as that user and created a feature flag (ordinary Editor action).\n- Captured webhook payload: `\"createdBy\": \"evil2\u003chttps://attacker.example/urgent-rollback|Click here to view incident\u003e\"` \u2014 `\u003c`, `\u003e`, `|` completely unescaped, live Slack link-injection syntax.\n- Control test with the same pinned `mustache@4.2.0` package confirmed the default (pre-bug) output for the same string would have been `evil2\u0026lt;https:\u0026#x2F;\u0026#x2F;attacker.example\u0026#x2F;urgent-rollback|Click here to view incident\u0026gt;` \u2014 i.e. the single global assignment is solely responsible for the unescaped output observed live.",
  "id": "GHSA-w4mq-xh27-6xpx",
  "modified": "2026-08-21T19:14:52Z",
  "published": "2026-08-21T19:14:52Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Unleash/unleash/security/advisories/GHSA-w4mq-xh27-6xpx"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Unleash/unleash/commit/002012cfdbedd2e9b7db9dc83b9f549f761db22e"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Unleash/unleash"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Unleash/unleash/releases/tag/v8.0.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Unleash: Global Mustache.escape override disables HTML escaping process-wide, enabling Slack/Teams link-injection via unrestricted username"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…