This is a bad doorbell button, not a broken front door
CVE-2025-21610 is an XSS flaw in the trix npm package where the editor accepted a javascript: link in the link dialog instead of rejecting it. The authoritative affected range is < 2.1.12 and the fix shipped in 2.1.12 on 2025-01-03; note that one advisory description line says "prior to 2.1.11," but the GHSA/NVD affected-version metadata and the release notes point to 2.1.12 as the real boundary.
The vendor's MEDIUM 5.3 is technically fair in CVSS terms, but operationally it still overstates enterprise urgency. Abuse depends on a chain of weak prerequisites: an attacker has to get someone into the rich-text workflow, convince them to paste or create a malicious link, and then get that link clicked in a vulnerable rendering context; that is a long way from unauthenticated remote compromise.
4 steps from start to impact.
Reach a vulnerable Trix workflow
trix code, typically as a browser-side dependency in a form, comment box, ticket field, or CMS editor. The vulnerable component is the client-side editor, not a server daemon, so the reachable surface is limited to applications exposing authoring UI.- Application bundles
trixversion< 2.1.12 - Users can access a page that exposes the Trix link dialog
- The app does not already validate or rewrite unsafe URI schemes
- Many enterprise assets never expose Trix to untrusted users
- SCA/SBOM tools usually catch the vulnerable package version quickly
- Internet-wide exposure tools do not reliably fingerprint bundled front-end assets
Abuse the link dialog with a javascript: payload
javascript:... into the Trix link field so the editor preserves an executable URI. The public PoC from th4s1s demonstrates the behavior against the Trix demo site and the fix commit adds explicit href validation using DOMPurify-backed checks.- Attacker can author content themselves or socially engineer a user to paste a malicious URI
- The application trusts Trix's generated link markup
- No allowlist restricts links to safe schemes like
http/https/mailto
- This is high-complexity, user-driven abuse rather than commodity spray-and-pray exploitation
- If the attacker does not already have content-authoring rights, they must rely on social engineering
- Some apps validate links again server-side or on render
Persist or render the malicious link
- The application persists and later re-renders Trix-authored HTML
- Rendered content remains clickable
- Higher-value users view the content in the same app origin
- Not every Trix deployment republishes authored content to other users
- Some workflows keep links non-clickable during editing or sanitize on save
- Modern CSP can materially reduce or break
javascript:execution paths
href="javascript: or href="data: patterns.Execute in browser session context
- A victim clicks the malicious link
- Browser and CSP settings permit the execution path
- The victim has a meaningful authenticated session in the application
- A second user action is still required
- CSP can break common XSS payloads even when the link is preserved
- Impact is bounded to browser/session context rather than host takeover
javascript: navigations, CSP violation reports, and app logs tied to rich-text content views.The supporting signals.
| In-the-wild status | No known active exploitation found in authoritative public sources reviewed here; not present in CISA KEV. |
|---|---|
| Proof-of-concept availability | Yes: public gist PoC by th4s1s shows javascript:alert('XSS') entered via the Trix link dialog and later executed on click. |
| EPSS | 0.2% (42nd percentile) in the GitHub advisory, which lines up with a low-likelihood, user-dependent abuse path. |
| KEV status | Not KEV-listed as of the reviewed CISA catalog URL; no ransomware-use signal noted. |
| CVSS vector | CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N — the important parts are AC:H and UI:R, which match the real-world friction. |
| Affected versions | All trix versions < 2.1.12 per GHSA/NVD/GitLab advisory metadata. One description line says "prior to 2.1.11"; treat that as advisory text drift, not the authoritative range. |
| Fixed versions | 2.1.12 fixes this specific issue. I found no distro backport guidance; this is primarily an npm/library upgrade problem. |
| Scanning and exposure | Low external visibility: this is a bundled client-side npm dependency, so Shodan/Censys/FOFA-style internet exposure counts are generally not meaningful here. *Inference based on package type and deployment model.* |
| Disclosure timeline | 2025-01-03 disclosure/publish date in GHSA and NVD; release v2.1.12 on the same date references the XSS fix. |
| Reporter / credit | GHSA credits HackerOne researcher lio346; the public PoC gist is attributed to th4s1s. |
noisgate verdict.
The decisive factor is the stacked user dependence: exploitation requires a vulnerable editor workflow, a malicious javascript: link to be introduced, and then a user to click it in a useful rendering context. That makes this a niche browser-session issue with limited reach, not a broad enterprise exploitation opportunity.
Why this verdict
- Downgrade for attacker position: this is not unauthenticated server-side reachability; it is a client-side library bug that only matters where Trix is actually exposed in a browser workflow.
- Downgrade for prerequisite chain: the path requires user interaction and usually either authoring access or social engineering to get a malicious link into the editor, then another click to fire it.
- Downgrade for limited blast radius: successful exploitation lands in the browser session of one application origin, not host-level code execution, domain compromise, or infrastructure-wide propagation.
- Slight upward pressure for app context: if low-privileged users can create content later viewed by admins, this can become stored click-XSS with meaningful application impact.
- No threat pressure: no KEV listing, no public in-the-wild campaign evidence, and very low EPSS all argue against emergency prioritization.
Why not higher?
This is missing the usual amplifiers that justify MEDIUM-to-HIGH patch urgency in enterprise operations: no zero-click path, no server compromise, no auth bypass, and no active exploitation evidence. The need for both content creation abuse and a later click event is real friction, not theoretical friction.
Why not lower?
It is still a genuine XSS class defect in a widely used package, not noise. In the wrong app design—especially where untrusted users author content consumed by privileged users—the bug can become a practical stored click-XSS path, so IGNORE would be too dismissive.
What to do — in priority order.
- Enforce a strict CSP — Apply or tighten
Content-Security-Policyso unexpected script execution paths are constrained even if a bad link is stored. For a LOW verdict there is no SLA (treat as backlog hygiene), so do this in the next normal web-security hardening cycle; if the editor sits in customer-facing or admin-adjacent content workflows, do it in the same sprint as the package bump. - Allowlist link schemes — Validate
hrefvalues on both save and render, permitting only schemes you actually need such ashttp,https, andmailto. This blocks the exploit class regardless of the front-end library version; for LOW, fold it into routine backlog work unless the app exposes multi-user authored content. - Hunt stored rich text for unsafe URIs — Search persisted content for
javascript:and suspiciousdata:links, especially in knowledge bases, ticket comments, and CMS entries created before the upgrade. This is low-cost validation and good hygiene for shared-content apps; run it during normal remediation rather than as an emergency response item.
- A WAF is not a strong control here because the dangerous behavior lives in a browser-side rich-text workflow and may be replayed from stored content rather than obvious exploit traffic.
- EDR on endpoints will not reliably stop same-origin JavaScript running inside the browser tab; this is an app-layer control problem first.
- Relying on developer-only dependency upgrades without content cleanup does not remove already-stored malicious links from application data.
Crowdsourced verification payload.
Run this on the application source tree, CI workspace, or build artifact that contains your Node dependencies. Invoke it as python verify_trix_cve_2025_21610.py /path/to/app with no elevated privileges; it checks installed node_modules, lockfiles, and package manifests, then prints VULNERABLE, PATCHED, or UNKNOWN.
#!/usr/bin/env python3
"""
verify_trix_cve_2025_21610.py
Check whether a project uses vulnerable versions of the npm package `trix`
affected by CVE-2025-21610.
Exit codes:
0 = PATCHED
1 = VULNERABLE
2 = UNKNOWN
"""
import json
import os
import re
import sys
from pathlib import Path
FIXED = (2, 1, 12)
PKG = "trix"
def parse_version(v):
if not v:
return None
v = v.strip()
v = v.lstrip('^~<>=v ')
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', v)
if not m:
return None
return tuple(int(x) for x in m.groups())
def is_vulnerable(ver_tuple):
return ver_tuple is not None and ver_tuple < FIXED
def read_json(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return None
def check_node_modules(root):
p = root / 'node_modules' / PKG / 'package.json'
if not p.exists():
return None, None
data = read_json(p)
if not data:
return None, None
ver = parse_version(data.get('version'))
return str(p), ver
def walk_package_lock_deps(deps, prefix=''):
found = []
if not isinstance(deps, dict):
return found
for name, meta in deps.items():
if name == PKG and isinstance(meta, dict):
ver = parse_version(meta.get('version', ''))
found.append((prefix + name, ver))
if isinstance(meta, dict) and 'dependencies' in meta:
found.extend(walk_package_lock_deps(meta.get('dependencies'), prefix + name + ' > '))
return found
def check_package_lock(root):
p = root / 'package-lock.json'
if not p.exists():
return []
data = read_json(p)
if not data:
return []
found = []
packages = data.get('packages')
if isinstance(packages, dict):
for pkg_path, meta in packages.items():
if pkg_path.endswith(f'node_modules/{PKG}') and isinstance(meta, dict):
found.append((f'{p}:{pkg_path}', parse_version(meta.get('version', ''))))
deps = data.get('dependencies')
found.extend((f'{p}:{path}', ver) for path, ver in walk_package_lock_deps(deps))
return found
def check_pnpm_lock(root):
p = root / 'pnpm-lock.yaml'
if not p.exists():
return []
results = []
try:
text = p.read_text(encoding='utf-8', errors='ignore')
except Exception:
return []
for m in re.finditer(r'^[ \t]*trix@[^:]*:\s*$(?:\n^[ \t]+.*$)*', text, flags=re.M):
block = m.group(0)
vm = re.search(r'^[ \t]*version:\s*([0-9]+\.[0-9]+\.[0-9]+)', block, flags=re.M)
if vm:
results.append((f'{p}:trix', parse_version(vm.group(1))))
if not results:
for m in re.finditer(r'/trix@([0-9]+\.[0-9]+\.[0-9]+):', text):
results.append((f'{p}:/trix', parse_version(m.group(1))))
return results
def check_yarn_lock(root):
p = root / 'yarn.lock'
if not p.exists():
return []
try:
text = p.read_text(encoding='utf-8', errors='ignore')
except Exception:
return []
results = []
blocks = re.split(r'\n(?=(?:"?[^"]+"?:))', text)
for block in blocks:
if re.search(r'^(?:"?trix@|trix@)', block):
vm = re.search(r'\n\s*version\s+"([0-9]+\.[0-9]+\.[0-9]+)"', block)
if vm:
results.append((f'{p}:trix', parse_version(vm.group(1))))
return results
def check_package_json_ranges(root):
p = root / 'package.json'
if not p.exists():
return []
data = read_json(p)
if not data:
return []
results = []
for section in ('dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'):
deps = data.get(section, {})
if isinstance(deps, dict) and PKG in deps:
results.append((f'{p}:{section}', deps[PKG]))
return results
def main():
root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd().resolve()
if not root.exists() or not root.is_dir():
print('UNKNOWN - target path does not exist or is not a directory')
sys.exit(2)
findings = []
nm_path, nm_ver = check_node_modules(root)
if nm_path and nm_ver:
findings.append((nm_path, nm_ver))
findings.extend(check_package_lock(root))
findings.extend(check_pnpm_lock(root))
findings.extend(check_yarn_lock(root))
dedup = []
seen = set()
for loc, ver in findings:
key = (loc, ver)
if key not in seen and ver is not None:
seen.add(key)
dedup.append((loc, ver))
vulnerable = [(loc, ver) for loc, ver in dedup if is_vulnerable(ver)]
patched = [(loc, ver) for loc, ver in dedup if not is_vulnerable(ver)]
if vulnerable:
versions = ', '.join(sorted({'.'.join(map(str, v)) for _, v in vulnerable}))
locations = '; '.join(loc for loc, _ in vulnerable[:5])
print(f'VULNERABLE - found {PKG} version(s) {versions} at {locations}')
sys.exit(1)
if patched:
versions = ', '.join(sorted({'.'.join(map(str, v)) for _, v in patched}))
print(f'PATCHED - found {PKG} version(s) {versions}; fixed threshold is 2.1.12')
sys.exit(0)
declared = check_package_json_ranges(root)
if declared:
refs = '; '.join(f'{loc}={spec}' for loc, spec in declared)
print(f'UNKNOWN - {PKG} declared in manifest but no installed/locked version resolved: {refs}')
sys.exit(2)
print(f'UNKNOWN - no evidence of {PKG} found in node_modules, lockfiles, or package.json under {root}')
sys.exit(2)
if __name__ == '__main__':
main()
If you remember one thing.
trix < 2.1.12, then sort them by one question: can untrusted or low-privilege users create content that higher-privilege users later click? For this LOW verdict there is no noisgate mitigation SLA and no noisgate remediation SLA**—treat it as backlog hygiene—but do not ignore customer-facing or admin-adjacent editors: queue the package upgrade in the next normal frontend release cycle and add temporary href` scheme allowlisting or CSP in the same sprint where that workflow is genuinely exposed.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.