← Back to Feed CACHED · 2026-06-07 00:53:55 · CACHE_KEY CVE-2026-39970
CVE-2026-39970 · CWE-79 · Disclosed 2026-05-22

Typebot stored XSS via SVG profile picture upload

ASSESSED — NOISGATE
Vendor
—
—
—
Reassessed
—
—
—
Verdict: —
Do you agree?
01 · The Real Story

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.

"Authenticated SVG upload plus a victim click on a separate file-hosting origin makes this real, but not urgent."
02 · The Attack Path

5 steps from start to impact.

STEP 01

Get low-privileged builder access with Burp or a browser

The attacker needs a legitimate Typebot account that can reach the profile picture form in the builder UI. Using a normal browser workflow or an intercepting proxy like Burp Suite, they upload an SVG containing scriptable payloads instead of a benign image.
Conditions required:
  • Authenticated access to a vulnerable Typebot instance
  • Version <= 3.15.2
  • Profile/avatar upload allowed for the account
Where this breaks in practice:
  • 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
Detection/coverage: Authenticated DAST or manual testing will find it; unauthenticated perimeter scanners generally will not.
STEP 02

Land the payload on public object storage

The vulnerable flow stores the SVG and exposes it at a stable public URL. The weaponized file becomes a persistent hosted object the attacker can reuse in phishing, chat, ticketing, or internal collaboration messages.
Conditions required:
  • Upload succeeds without SVG sanitization
  • Uploaded object is retrievable over HTTP(S)
Where this breaks in practice:
  • 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
Detection/coverage: S3/object-store logs, reverse-proxy logs, and content-type anomaly checks can catch suspicious .svg uploads and fetches.
STEP 03

Social-engineer the victim into opening the raw SVG

The payload needs a victim to directly open the hosted SVG or otherwise render it in a script-capable context. Typical tooling is plain delivery: email, chat, issue tracker comments, or a link pasted into a Typebot-adjacent workflow.
Conditions required:
  • Victim receives the exact file URL
  • Victim opens it in a browser context that executes SVG script
Where this breaks in practice:
  • 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
Detection/coverage: URL-click telemetry and web proxy logs provide the best visibility; vulnerability scanners do not see this step.
STEP 04

Execute JavaScript on the file origin

If the victim opens the SVG in a permissive browser context, attacker-controlled JavaScript runs with the origin of the hosted file. Tools are trivial here: inline <script>, external fetches, or credential-harvesting DOM overlays.
Conditions required:
  • Browser executes active content in the delivered SVG
  • Response headers and embedding context do not suppress execution
Where this breaks in practice:
  • 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
Detection/coverage: Browser EDR, secure web gateways, and CSP/reporting can surface abnormal outbound requests from opened SVG content.
STEP 05

Monetize only if origin boundaries are weak

The best-case attacker outcome is phishing, token theft from the file origin, or abuse of any shared parent-domain trust. In self-hosted deployments that serve uploads from the same origin, or that scope cookies too broadly, impact can climb sharply; on default separated hosting, the likely outcome is narrower client-side abuse rather than clean account takeover.
Conditions required:
  • Shared auth scope, weak cookie domain settings, or same-origin file serving
  • Victim has useful privileges
Where this breaks in practice:
  • Default cloud-style split hosting heavily reduces value
  • HTTPOnly cookies, origin separation, and modern browser controls limit post-execution leverage
Detection/coverage: Review cookie domain settings, storage origin architecture, and any CSP violation reports; those determine whether impact is nuisance-level or business-impacting.
03 · Compensating Control

1
Block SVG avatar uploads — Enforce MIME and extension restrictions for profile/avatar uploads so only non-active image types like PNG/JPEG are accepted. If you cannot patch immediately, deploy this as your first compensating control; for a MEDIUM verdict there is no mitigation SLA, but it is still the cleanest temporary risk reduction.
2
Force downloads for uploaded SVG — Serve any existing .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.
3
Separate auth and file origins — Keep uploaded content on an origin that does not share cookies, local storage, or parent-domain auth scope with the builder app. This matters most on self-hosted deployments and should be validated before your 365-day remediation deadline.
4
Audit builder-account sprawl — Review who can create or edit workspaces and who can upload profile assets. Because exploitation starts with a valid account, pruning stale or over-privileged builder users meaningfully lowers exposure while you schedule the patch.
5
Hunt for existing malicious SVGs — Search your object store or upload directory for .svg avatars and unusual XML/script content. Removing already-planted payloads limits persistence even if some nodes remain unpatched during the remediation window.
What doesn't work
  • 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 HttpOnly alone 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.
04 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo public evidence of active exploitation found as of 2026-05-29 in the sources reviewed; not KEV-listed.
Proof-of-concept availabilityPublic 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.
EPSSUser-supplied EPSS is 0.00052. That is extremely low and lines up with the real-world friction in the exploit chain.
KEV statusNo entry in CISA KEV. No KEV due date applies.
Affected versionsGitHub advisory lists Typebot <= 3.15.2 as affected.
Fixed versionPatched in 3.16.0. Release notes also mention fix XSS on Rating and file upload inputs.
Severity baselineThere is no authoritative vendor CVSS baseline on the advisory/NVD pages reviewed, so this rating is first-principles assessed, not compared.
Practical vectorRealistically 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 realityInternet 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 creditPublished 2026-05-22 via GitHub Security Advisory GHSA-jj87-c343-26vp; reporter credited as bugshunter68-lgtm.

Sources.

  1. GitHub Security Advisory GHSA-jj87-c343-26vp
  2. Typebot release v3.16.0
  3. NVD CVE-2026-39970
  4. Typebot repository README
  5. Typebot docs overview
  6. Typebot settings docs
  7. CISA Known Exploited Vulnerabilities Catalog
  8. FIRST EPSS data documentation
05 · The Call

Final Verdict
= UNCHANGED to MEDIUM (4.8/10)

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 than app.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.

06 · Verification

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.

noisgate-verify.sh
BASHREAD-ONLYSAFE
#!/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