# Bitwarden: Org Member Can Inject JSON into Webhook/SIEM Payloads via Display Name
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:
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:
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 ActingUserId → ActingUserName, 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):
","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:
// 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):
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:
+ 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, commita26afd18. - ➜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.