Like leaving a loaded staple gun in the editors’ room instead of the public lobby
CVE-2025-22388 is a stored XSS issue in Optimizely EPiServer.CMS.Core affecting versions before 12.22.0. The vendor says the injection points sit in content editing, link management, and file uploads, which means an attacker can store JavaScript in CMS workflows and wait for another user to render that content inside the Optimizely origin.
The raw impact is real because XSS inside a CMS can steal data, impersonate user actions, and pivot into site defacement or admin misuse. But the vendor's HIGH 8.1 overstates the operational risk for most enterprises: this path is typically authenticated, usually requires an editor/admin workflow, and still needs someone to view the tainted object, so this is better treated as a post-initial-access CMS compromise amplifier than a broad unauthenticated edge exploit.
4 steps from start to impact.
Get into the CMS editing surface
/episerver path and associated CMS roles. Optimizely's own docs show WebEditors and WebAdmins are the groups that unlock edit/admin views, so this is not a public-anonymous path in normal deployments.- Valid Optimizely CMS account or equivalent SSO session
- Membership in WebEditors/WebAdmins or mapped CMS editor/admin roles
- Reachability to the CMS edit/admin surface
- MFA/SSO and conditional access often gate editor access
- Many orgs keep edit/admin paths behind VPN, IP allowlists, or private admin networks
- A plain web visitor cannot normally reach the vulnerable workflow
Store the payload through editor features
- Ability to create or modify content, links, or uploaded files
- A vulnerable EPiServer.CMS.Core version below 12.22.0
- Least-privilege editorial rights may block some asset libraries or content trees
- Input filtering, custom validators, or upload restrictions can break common payloads
- Some deployments have CSP or custom sanitizers that reduce exploit reliability
Wait for a victim with useful rights to render it
- A victim user opens the affected CMS object or preview
- The payload survives storage and render phases
- No user view means no execution
- Narrow editorial teams reduce trigger frequency
- Preview-only or role-limited content may never be seen by a high-value admin
Abuse same-origin trust inside the CMS
- Successful script execution in the victim browser
- Victim account has meaningful CMS permissions
- Blast radius is bounded by the victim role and site scope
- Strong segregation between editor and admin duties limits escalation
- Some sensitive actions may require re-authentication or separate approval
The supporting signals.
| In-the-wild status | No authoritative exploitation evidence found in the reviewed public sources. The CISA weekly bulletin lists disclosure, but not active abuse. |
|---|---|
| KEV status | Not KEV-listed in the reviewed CISA KEV catalog. That materially lowers urgency versus truly burning edge bugs. |
| EPSS | User-supplied EPSS 0.00689 is very low. FIRST documents EPSS as a 30-day exploitation probability model via its API/docs and model description. |
| Public PoC availability | No direct public PoC for CVE-2025-22388 surfaced in the reviewed sources. However, later SEC Consult research on adjacent Optimizely CMS stored-XSS paths provides realistic exploit mechanics and confirms this product area is fertile ground for editor-driven stored XSS: advisory. |
| Vendor vs registry scoring | There is a scoring split. The vendor advisory rates this HIGH 8.1 without publishing a vector, while NVD/CISA-ADP shows 5.7 MEDIUM with AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N. |
| Affected versions | Vendor and NVD both point to EPiServer.CMS.Core before 12.22.0. |
| Fixed version | The official fix is EPiServer.CMS.Core 12.22.0 per the Optimizely security advisory. |
| Attacker position required | This is the decisive friction point: Optimizely documents that WebEditors and WebAdmins provide access to CMS edit/admin views in initial configuration. That implies a normal attacker needs authenticated remote access into the editorial plane first. |
| Exposure and scanning reality | No trustworthy product-specific GreyNoise/Censys/Shodan census was identified in the reviewed sources for this CVE. Operationally, exposure is narrower than a generic web XSS because the interesting surface is the CMS edit/admin plane, not just the public site; Optimizely's own docs show access occurs through the CMS login/edit workflow, often at /episerver in default setups (example doc). |
| Disclosure and reporting | Optimizely published the advisory on 2025-01-03 and updated it on 2025-01-06: vendor advisory. A CISA bulletin entry also appeared the week of January 6, 2025. |
noisgate verdict.
The single biggest downward pressure is that this bug lives in the authenticated CMS editorial surface, not the anonymous public site. That makes it a post-compromise or malicious-editor multiplier with real impact, but a much smaller reachable population than the vendor's headline score suggests.
Why this verdict
- Downgrade from vendor HIGH 8.1: the vendor describes stored XSS in editing, link, and upload workflows, which strongly points to an authenticated CMS-user path rather than anonymous web traffic.
- PR:L + UI:R matters in production: the published
PR:LandUI:Rvector means the attacker needs a foothold in the editorial plane and then a victim to render the payload; those are two separate gates, not one. - Reachable population is narrow: Optimizely documents WebEditors/WebAdmins as the groups for CMS edit/admin access, so many enterprises already confine the vulnerable surface behind SSO, VPN, admin URLs, or role-based access.
- No current threat amplifier: no KEV listing, no reviewed public exploitation evidence, and a very low user-supplied EPSS score all argue against treating this like an emergency edge compromise issue.
- Impact is still real once triggered: same-origin JavaScript inside a CMS can absolutely hijack workflows, alter content, and abuse victim privileges, so this is not backlog junk.
Why not higher?
I am not calling this HIGH because the attack chain is compounded by three practical brakes: authenticated access, editor/admin-only workflow reachability, and victim interaction. That makes it materially different from an unauthenticated internet-facing appliance bug or a one-shot RCE where every exposed host is immediately targetable.
Why not lower?
I am not calling this LOW because stored XSS inside a CMS is not cosmetic. If a privileged editor or admin triggers the payload, the attacker can drive real same-origin actions, harvest data visible to that session, and tamper with content at scale inside a business-critical platform.
What to do — in priority order.
- Constrain editor access — Put CMS edit/admin access behind SSO+MFA, VPN, IP allowlists, or private admin networking if it is not already. This directly attacks the most important prerequisite in the chain and should be in place within the MEDIUM handling window as risk reduction, even though patching is the main fix.
- Strip dangerous upload types — Block or heavily gate SVG, HTML, and other active content in CMS-managed uploads unless there is a hard business need. The vendor explicitly calls out file uploads as part of the vulnerable area, so this reduces one of the easiest payload carriers.
- Harden input handling in editor workflows — Apply server-side sanitization and validation to rich text, link fields, and custom content properties where your implementation extends default behavior. This is especially useful for orgs that cannot patch immediately and should be applied during the normal MEDIUM remediation window.
- Turn on CSP reporting for admin surfaces — A restrictive Content Security Policy with reporting can blunt some payload execution patterns and gives defenders detection value when the CMS UI is probed with inline-script tricks. It is not a fix, but it is one of the few controls that can both reduce blast radius and surface abuse.
- Review editor/admin role sprawl — Audit who actually has WebEditors/WebAdmins or mapped roles and remove stale access. Fewer editorial users means fewer accounts that can plant a payload and fewer likely victims who can trigger one.
- A public-site WAF alone does not solve this well, because the risky workflows sit in authenticated CMS edit/admin traffic and in stored content that executes later.
- MFA alone is not enough; it helps prevent account takeover, but it does nothing against a malicious insider or a user whose session is already active inside the CMS.
- Cookie HttpOnly alone is not enough; XSS can still perform authenticated same-origin actions even when it cannot read the raw session cookie.
Crowdsourced verification payload.
Run this on the target CMS host or against a mounted application artifact directory, not from an auditor workstation with no app files. Invoke it as python3 check_optimizely_cve_2025_22388.py /var/www/site or python check_optimizely_cve_2025_22388.py C:\inetpub\wwwroot\MyCms; no admin rights are required, but the account needs read access to the deployed app files.
#!/usr/bin/env python3
# check_optimizely_cve_2025_22388.py
# Detects whether a deployed/app source tree appears to use EPiServer.CMS.Core < 12.22.0
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN, 3=USAGE ERROR
import json
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
FIXED = (12, 22, 0)
PKG = 'episerver.cms.core'
def parse_version(v):
if not v:
return None
v = v.strip()
v = re.sub(r'^[\[\(\s]*', '', v)
v = re.sub(r'[\]\)\s]*$', '', v)
v = v.split(',')[0].strip()
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', v)
if not m:
return None
return tuple(int(x) for x in m.groups())
def fmt(ver):
return '.'.join(str(x) for x in ver)
def is_vulnerable(ver):
return ver is not None and ver < FIXED
def scan_deps_json(path):
findings = []
try:
data = json.loads(path.read_text(encoding='utf-8', errors='ignore'))
except Exception:
return findings
for section in ('libraries', 'targets'):
obj = data.get(section, {})
if not isinstance(obj, dict):
continue
stack = [obj]
while stack:
cur = stack.pop()
if isinstance(cur, dict):
for k, v in cur.items():
lk = str(k).lower()
if lk.startswith(PKG + '/'):
ver = parse_version(lk.split('/', 1)[1])
if ver:
findings.append((str(path), ver, 'deps.json'))
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(cur, list):
for item in cur:
if isinstance(item, (dict, list)):
stack.append(item)
return findings
def scan_csproj(path):
findings = []
try:
root = ET.parse(path).getroot()
except Exception:
return findings
for elem in root.iter():
if elem.tag.lower().endswith('packagereference'):
include = (elem.attrib.get('Include') or elem.attrib.get('Update') or '').strip().lower()
if include == PKG:
ver = elem.attrib.get('Version') or ''
if not ver:
for child in elem:
if child.tag.lower().endswith('version') and child.text:
ver = child.text
break
pver = parse_version(ver)
if pver:
findings.append((str(path), pver, 'csproj'))
return findings
def scan_packages_config(path):
findings = []
try:
root = ET.parse(path).getroot()
except Exception:
return findings
for pkg in root.findall('.//package'):
pkg_id = (pkg.attrib.get('id') or '').strip().lower()
if pkg_id == PKG:
ver = parse_version(pkg.attrib.get('version'))
if ver:
findings.append((str(path), ver, 'packages.config'))
return findings
def main():
if len(sys.argv) != 2:
print('UNKNOWN - usage: python check_optimizely_cve_2025_22388.py <path>')
sys.exit(3)
base = Path(sys.argv[1])
if not base.exists():
print(f'UNKNOWN - path does not exist: {base}')
sys.exit(2)
findings = []
for root, dirs, files in os.walk(base):
# Skip common heavy/unhelpful trees
dirs[:] = [d for d in dirs if d.lower() not in {'.git', 'node_modules', '.vs', 'bin', 'obj'}]
for name in files:
p = Path(root) / name
lname = name.lower()
if lname.endswith('.deps.json'):
findings.extend(scan_deps_json(p))
elif lname.endswith('.csproj'):
findings.extend(scan_csproj(p))
elif lname == 'packages.config':
findings.extend(scan_packages_config(p))
if not findings:
print('UNKNOWN - could not locate EPiServer.CMS.Core version in .deps.json, .csproj, or packages.config')
sys.exit(2)
# Choose the highest discovered version as the most useful signal in mixed trees
best = max(findings, key=lambda x: x[1])
vuln_hits = [f for f in findings if is_vulnerable(f[1])]
if vuln_hits:
worst = min(vuln_hits, key=lambda x: x[1])
print(f'VULNERABLE - found {PKG} {fmt(worst[1])} in {worst[0]} via {worst[2]}; fixed version is {fmt(FIXED)}')
sys.exit(1)
print(f'PATCHED - highest discovered {PKG} version is {fmt(best[1])}; fixed threshold is {fmt(FIXED)}')
sys.exit(0)
if __name__ == '__main__':
main()
If you remember one thing.
Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.