This is a booby-trapped badge photo pinned to a public bulletin board, not a master key to the building
CVE-2026-39970 is a stored XSS flaw in Typebot's profile-picture upload flow: affected builds <= 3.15.2 let an authenticated user upload a malicious SVG as an avatar, and the file is then publicly retrievable. The bug is persistence, not code execution on the server; the attacker plants active browser content inside an uploaded SVG and waits for someone to open the hosted file.
The original advisory language calls this *critical*, but that overshoots the real deployment story. In practice the chain needs authenticated upload rights, a victim who opens the raw SVG URL, and useful security context on the file-serving origin; on Typebot Cloud the sample payload is hosted from s3.typebot.io, which materially limits blast radius versus a same-origin app XSS. That combination keeps this out of the emergency lane even though the underlying bug is valid.
5 steps from start to impact.
Get low-privileged builder access with Burp or a browser
Burp Suite, they upload an SVG containing scriptable payloads instead of a benign image.- Authenticated access to a vulnerable Typebot instance
- Version
<= 3.15.2 - Profile/avatar upload allowed for the account
- This is not unauthenticated internet-to-RCE; the attacker already has an account
- Many enterprise Typebot deployments are admin-only or tightly scoped to internal staff
Land the payload on public object storage
- Upload succeeds without SVG sanitization
- Uploaded object is retrievable over HTTP(S)
- The advisory sample shows storage on
s3.typebot.io, not the main app origin - If the browser treats the file as an image in an
<img>context, script usually does not execute
.svg uploads and fetches.Social-engineer the victim into opening the raw SVG
- Victim receives the exact file URL
- Victim opens it in a browser context that executes SVG script
- This is a *user-action* bug, not an auto-trigger from merely existing in storage
- Secure email gateways, browser isolation, and user skepticism break a lot of these campaigns
Execute JavaScript on the file origin
<script>, external fetches, or credential-harvesting DOM overlays.- Browser executes active content in the delivered SVG
- Response headers and embedding context do not suppress execution
- Running on a storage origin is not the same as running on
app.typebot.io - Same-origin protections block direct access to app cookies/local storage unless cookie scoping or self-hosting choices collapse the boundary
Monetize only if origin boundaries are weak
- Shared auth scope, weak cookie domain settings, or same-origin file serving
- Victim has useful privileges
- Default cloud-style split hosting heavily reduces value
- HTTPOnly cookies, origin separation, and modern browser controls limit post-execution leverage
.svg objects with Content-Disposition: attachment and a non-executable content type where your stack allows it. This reduces script-capable rendering while you move to 3.16.0 within the remediation window..svg avatars and unusual XML/script content. Removing already-planted payloads limits persistence even if some nodes remain unpatched during the remediation window.- A generic network IPS does not solve this because the dangerous payload is a valid file upload and later a normal HTTPS object fetch.
- Relying on perimeter internet-exposure scans does not solve this because the exploitable step is behind authentication and user workflow, not a naked bannered service.
- Assuming
HttpOnlyalone makes it harmless does not solve this because attackers can still phish, abuse DOM APIs on the file origin, or exploit weak self-hosted origin/cookie layouts.
The supporting signals.
| In-the-wild status | No public evidence of active exploitation found as of 2026-05-29 in the sources reviewed; not KEV-listed. |
|---|---|
| Proof-of-concept availability | Public PoC is effectively included in the GitHub advisory: malicious SVG upload plus a public object URL and demo artifact. That makes reproduction straightforward for anyone with a low-priv account. |
| EPSS | User-supplied EPSS is 0.00052. That is extremely low and lines up with the real-world friction in the exploit chain. |
| KEV status | No entry in CISA KEV. No KEV due date applies. |
| Affected versions | GitHub advisory lists Typebot <= 3.15.2 as affected. |
| Fixed version | Patched in 3.16.0. Release notes also mention fix XSS on Rating and file upload inputs. |
| Severity baseline | There is no authoritative vendor CVSS baseline on the advisory/NVD pages reviewed, so this rating is first-principles assessed, not compared. |
| Practical vector | Realistically this behaves like authenticated remote upload + victim interaction + client-side execution on the file-hosting origin. That is materially weaker than a same-origin stored XSS in the main app. |
| Exposure/scanning reality | Internet search engines are the wrong lens here: the risky step is an authenticated upload workflow followed by public object access, not a distinct exposed service banner. Public exposure volume therefore depends more on who can get builder accounts than on Shodan/Censys discoverability. |
| Disclosure and credit | Published 2026-05-22 via GitHub Security Advisory GHSA-jj87-c343-26vp; reporter credited as bugshunter68-lgtm. |
Sources.
Why this verdict
- Requires authenticated remote access: the attacker must already hold a valid Typebot account that can hit the avatar upload flow. That is immediate downward pressure because this is post-account-acquisition, not perimeter compromise.
- Needs victim interaction: the payload is not a worm sitting in the main UI; a victim must open the raw SVG or equivalent active rendering context. Email security, browser isolation, and ordinary user skepticism stop a meaningful slice of attempts.
- Default blast radius is usually origin-limited: the advisory's own sample shows hosting from
s3.typebot.io, which means script execution is likely on the storage origin rather thanapp.typebot.io. Unless self-hosting choices collapse that boundary, the attacker gets less than a normal same-origin stored XSS.
Why not higher?
It is not higher because the chain stacks three big frictions: authenticated access, victim click/open, and likely origin separation. Those are compounding brakes, not minor caveats. A same-origin admin-panel stored XSS with unauthenticated planting or automatic trigger behavior would be a different conversation.
Why not lower?
It is not lower because the bug is persistent, trivially reproducible, and weaponizable on a trusted vendor domain. If your deployment serves uploads from the same origin, uses broad cookie domains, or exposes builder access to many semi-trusted users, impact can jump from annoyance to real credential or session abuse.
Crowdsourced verification payload.
Run this on the Typebot host or inside the Typebot container where the application files are present. Invoke it as sudo bash check_cve_2026_39970.sh /opt/typebot or docker exec -it <typebot-container> bash /tmp/check_cve_2026_39970.sh /app; it needs read access to package.json only.
#!/usr/bin/env bash
# check_cve_2026_39970.sh
# Determine whether a local Typebot installation is affected by CVE-2026-39970.
# Usage: bash check_cve_2026_39970.sh /path/to/typebot
# Exit codes:
# 0 = PATCHED
# 1 = VULNERABLE
# 2 = UNKNOWN / error
set -euo pipefail
TARGET_DIR="${1:-}"
if [[ -z "$TARGET_DIR" ]]; then
echo "UNKNOWN: missing path argument"
exit 2
fi
PKG="$TARGET_DIR/package.json"
if [[ ! -f "$PKG" ]]; then
echo "UNKNOWN: package.json not found at $PKG"
exit 2
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "UNKNOWN: python3 is required for version comparison"
exit 2
fi
VERSION=$(python3 - <<'PY' "$PKG"
import json, sys
p = sys.argv[1]
try:
with open(p, 'r', encoding='utf-8') as f:
data = json.load(f)
v = data.get('version')
if not v:
print('')
else:
print(str(v).strip())
except Exception:
print('')
PY
)
if [[ -z "$VERSION" ]]; then
echo "UNKNOWN: could not read version from $PKG"
exit 2
fi
python3 - <<'PY' "$VERSION"
import re, sys
from itertools import zip_longest
version = sys.argv[1].strip()
# Normalize strings like v3.15.2, 3.15.2-beta, etc.
def normalize(v):
v = v.lstrip('vV')
main = re.split(r'[-+]', v, 1)[0]
parts = [int(x) for x in main.split('.') if x.isdigit()]
return parts
def cmp(a, b):
for x, y in zip_longest(a, b, fillvalue=0):
if x < y:
return -1
if x > y:
return 1
return 0
cur = normalize(version)
if not cur:
print('UNKNOWN: unparseable version')
sys.exit(2)
vuln_max = normalize('3.15.2')
patched_min = normalize('3.16.0')
if cmp(cur, vuln_max) <= 0:
print(f'VULNERABLE: Typebot {version} <= 3.15.2')
sys.exit(1)
elif cmp(cur, patched_min) >= 0:
print(f'PATCHED: Typebot {version} >= 3.16.0')
sys.exit(0)
else:
print(f'UNKNOWN: Typebot {version} falls in an unexpected comparison state')
sys.exit(2)
PY