CVE Patch Review

GHSA-8RQH-VXPR-X77P: Root-Cause Fix for Stored XSS in Plone RichText MIME Handling

GHSA-8RQH-VXPR-X77P · Updated 2026-07-18 Root-cause

Summary

The patch addresses a stored XSS path caused by trusting attacker-controlled rich text MIME metadata, especially the safe-HTML marker `text/x-html-safe`. The effective fix is split across two layers: `plone.app.textfield` now always routes safe-HTML output through sanitization instead of short-circuiting when input and output MIME types match, and `plone.restapi` rejects client attempts to submit rich text as already-sanitized safe HTML. Together these changes remove the trust boundary violation and add defense in depth.

Analysis

Vulnerability

GHSA-8RQH-VXPR-X77P describes a stored cross-site scripting issue in Plone rich text handling where untrusted input could bypass sanitization by claiming the MIME type text/x-html-safe. The vulnerable behavior was rooted in a trust decision based on MIME metadata rather than on actual sanitization. In plone.app.textfield, the transformation path skipped processing when input and output MIME types matched, which allowed attacker-controlled content labeled as safe HTML to avoid the safe_html transform. In plone.restapi, clients could submit rich text with a spoofed content-type indicating the payload was already sanitized.

The issue is stored XSS because the malicious markup can be persisted and later rendered to other users. The advisory and patch references show that both the value/rendering layer and the REST deserialization layer were involved, so mitigation requires coordinated updates to both packages: plone.app.textfield 4.0.0...4.0.1 and plone.restapi 9.15.5...9.15.6.

if self.mimeType == self.outputMimeType:
    # no transformation is performed

That short-circuit is the critical anti-pattern: it assumes MIME equality implies safety, even when the MIME value itself is attacker-controlled.

Patch

The patch in plone.app.textfield changes the rendering logic so that safe-HTML output is always sanitized, even when the stored input MIME type is also text/x-html-safe. The new code introduces explicit MIME constants, normalizes missing MIME values, and rewrites the special case where both input and output are safe HTML by treating the input as regular HTML for transformation purposes. This forces the safe_html transform to run and removes trust in the stored MIME label.

if mimeType == SAFE_HTML_MIME_TYPE and outputMimeType == SAFE_HTML_MIME_TYPE:
    # Treat stored safe HTML as HTML when rendering to the safe HTML
    # output type.
    mimeType = HTML_MIME_TYPE

return transformer(value, outputMimeType)

The same patch also adds tests proving that a payload containing an event handler is sanitized both for a default RichTextValue and for a value explicitly constructed with spoofed mimeType="text/x-html-safe".

The patch in plone.restapi adds a second control at the API boundary: deserialization now rejects rich text input that claims to be already sanitized by setting content-type to text/x-html-safe. The associated test asserts that deserializing such a payload raises ValueError. This is explicitly described in the changelog as defense in depth.

with self.assertRaises(ValueError):
    deserializer(
        {
            "data": "<img src=x onerror=alert(1)>",
            "content-type": "text/x-html-safe",
        }
    )

Source-grounded references: textfield patch, REST API patch, and GitHub Security Advisory.

Review

Pros

  • The primary flaw is addressed at the correct layer: rendering/sanitization no longer trusts the text/x-html-safe label. This directly fixes the trust-boundary error that enabled stored XSS.
  • The plone.app.textfield change is robust because it preserves the optimization for equal MIME types except for the sensitive safe-HTML case. That minimizes behavioral churn while fixing the dangerous branch.
  • The REST API change adds a useful boundary check by rejecting client-supplied text/x-html-safe. This reduces exploitability and prevents unsafe content from entering storage through the documented API path.
  • Regression coverage is good for the disclosed exploit path: one test exercises the value layer directly, and another exercises REST deserialization with a spoofed MIME type.
  • The patch comments clearly document the security rationale, especially the idempotence assumption for safe_html and the defense-in-depth nature of the REST rejection.

Cons

  • The REST API snippet appears narrowly scoped and depends on how content_type and orig_content_type are derived in surrounding code. The provided diff shows a conditional if content_type == "text/x-html-safe" and content_type != orig_content_type, which is only meaningful if later code mutates content_type. The compare view likely contains that context, but the isolated snippet is not self-evident.
  • The fix relies on the correctness and idempotence of the safe_html transform. That is a reasonable design assumption, but it means sanitizer quality remains a downstream dependency.
  • The patch evidence is focused on REST and rich text rendering. If other ingestion paths can set rich text MIME metadata directly, they still depend on the textfield-layer fix rather than benefiting from API-layer rejection.
  • Error handling in the REST layer is a hard reject via ValueError. That is secure, but integrators may need to verify user-facing API behavior and compatibility for clients that previously sent this MIME type.

Verdict

Root-cause.

The decisive fix is in plone.app.textfield: it removes the unsafe assumption that matching MIME types, specifically text/x-html-safe, imply content has already been sanitized. That was the root cause of the bypass. The plone.restapi change is additive defense in depth, blocking spoofed safe-HTML claims at the API boundary. Because the rendering layer now sanitizes safe-HTML output regardless of attacker-controlled MIME labeling, the patch meaningfully closes the stored XSS path described in the advisory. For deployment, engineers should treat the remediation as requiring both package upgrades referenced in the official patch comparisons: plone.app.textfield 4.0.1 and plone.restapi 9.15.6.

Sources