← Back to Feed CACHED · 2026-05-17 09:42:19 · cache_key CVE-2025-29912
CVE-2025-21610 · CWE-79 · Disclosed 2025-01-03

Trix is a what-you-see-is-what-you-get rich text editor for everyday writing

ASSESSED — NOISGATE V0.5
Vendor
Reassessed
Verdict:
01 · The Real Story

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.

"Real bug, weak priority: this is user-driven click-XSS in a front-end library, not an enterprise patch fire."
02 · The Attack Path

4 steps from start to impact.

STEP 01

Reach a vulnerable Trix workflow

The attacker needs an application that actually embeds vulnerable 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.
Conditions required:
  • Application bundles trix version < 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
Where this breaks in practice:
  • 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
Detection/coverage: Good SCA/SBOM coverage; weak network scanner coverage because this is a bundled browser library.
STEP 02

Abuse the link dialog with a javascript: payload

Per the GHSA and public gist PoC, the weaponized technique is to enter 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.
Conditions required:
  • 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
Where this breaks in practice:
  • 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
Detection/coverage: Static dependency checks find the version; DAST may miss it unless tests explicitly exercise the link dialog and click path.
STEP 03

Persist or render the malicious link

The practical risk appears when the application's stored rich text is later rendered as clickable content for the author or for other users. In the strongest real-world case, a low-privileged user can author content that is later viewed by a higher-privileged user in the same origin, turning this into stored click-XSS.
Conditions required:
  • 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
Where this breaks in practice:
  • 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
Detection/coverage: App-layer detection is best: inspect stored rich text for href="javascript: or href="data: patterns.
STEP 04

Execute in browser session context

If a user clicks the malicious link in a vulnerable render path, JavaScript executes in the application's web origin. That can expose session data or trigger user actions in-browser, but it does not become server-side code execution or platform-level compromise by itself.
Conditions required:
  • A victim clicks the malicious link
  • Browser and CSP settings permit the execution path
  • The victim has a meaningful authenticated session in the application
Where this breaks in practice:
  • 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
Detection/coverage: Look for suspicious javascript: navigations, CSP violation reports, and app logs tied to rich-text content views.
03 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo known active exploitation found in authoritative public sources reviewed here; not present in CISA KEV.
Proof-of-concept availabilityYes: public gist PoC by th4s1s shows javascript:alert('XSS') entered via the Trix link dialog and later executed on click.
EPSS0.2% (42nd percentile) in the GitHub advisory, which lines up with a low-likelihood, user-dependent abuse path.
KEV statusNot KEV-listed as of the reviewed CISA catalog URL; no ransomware-use signal noted.
CVSS vectorCVSS: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 versionsAll 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 versions2.1.12 fixes this specific issue. I found no distro backport guidance; this is primarily an npm/library upgrade problem.
Scanning and exposureLow 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 timeline2025-01-03 disclosure/publish date in GHSA and NVD; release v2.1.12 on the same date references the XSS fix.
Reporter / creditGHSA credits HackerOne researcher lio346; the public PoC gist is attributed to th4s1s.
04 · The Call

noisgate verdict.

Final Verdict
DOWNGRADED to LOW (2.9/10)

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.

HIGH Exploit preconditions and friction
MEDIUM Population exposed in typical enterprises

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.

05 · Compensating Control

What to do — in priority order.

  1. Enforce a strict CSP — Apply or tighten Content-Security-Policy so 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.
  2. Allowlist link schemes — Validate href values on both save and render, permitting only schemes you actually need such as http, https, and mailto. 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.
  3. Hunt stored rich text for unsafe URIs — Search persisted content for javascript: and suspicious data: 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.
What doesn't work
  • 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.
06 · Verification

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.

noisgate-verify.py
PYTHONREAD-ONLYSAFE
#!/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()
07 · Bottom Line

If you remember one thing.

TL;DR
Monday morning, have your AppSec/SCA pipeline identify every application that ships 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

  1. GitHub Advisory GHSA-j386-3444-qgwg
  2. NVD CVE-2025-21610
  3. Trix v2.1.12 release notes
  4. Fix commit 180c8d3
  5. Public PoC gist by th4s1s
  6. GitLab advisory database entry
  7. Trix repository README
  8. Snyk package popularity page for trix
Peer Review

What defenders are saying.

Submit a review attribution: handle + country only
0 flags selected · stored anonymously
Validation Results

Crowdsourced verification outputs.

Results submitted by users who ran the verification payload against their environment.