Skip to content

Stored XSS in competition pages / terms / phase descriptions #2502

Description

@dkhonker

I found two problems in codabench

  1. Where DOMPurify is used, the bundled version is 1.0.10 (August 2018), six years out of date (latest: 3.4.13), affected by every known mXSS bypass published since, including CVE-2020-26870 and CVE-2024-47875.
    Known XSS bypasses affecting 1.0.10 (all fixed in later versions):
Advisory Issue Affected range Fixed in
CVE-2020-26870 (GHSA-63q7-h895-m982) mXSS: serialise/parse round-trip changes the DOM; HTML→MathML namespace switch via nested FORM elements < 2.0.17 2.0.17
CVE-2024-47875 (GHSA-gx9m-whjm-85jf) Nesting-based mXSS (deeply nested elements + parser depth limits) < 2.5.0 2.5.0
CVE-2025-26791 mXSS via incorrect template-literal regex < 3.2.4 3.2.4
CVE-2026-41238 (GHSA-v9jr-rg53-9pgp) Prototype pollution → XSS via CUSTOM_ELEMENT_HANDLING fallback ≤ 3.3.x 3.4.0
CVE-2026-65898 (GHSA-cmwh-pvxp-8882) Permanent attribute allowlist pollution via setConfig < 3.4.11 3.4.11
CVE-2026-65904 (GHSA-4w3q-35jp-p934) XSS via IN_PLACE mode ≤ 3.3.3 3.4.x
  1. The competition "Pages" / "Terms" / "Phase description" rendering path never sanitizes HTML at all. renderMarkdownWithLatex() pipes raw HTML through marked (default sanitize: false) and inserts the parsed DOM nodes with appendChild. DOMPurify is not called anywhere in this path, so no bypass is needed — plain <img src=x onerror=...> or <script> in a page's content is rendered verbatim and executes for every visitor.

Code location (repo): src/static/js/ours/latex_markdown_html.js, function renderMarkdownWithLatex():

  • Line 61: let html = marked(contentWithLatexPlaceholders)marked@3.0.7 is loaded with its default options (sanitize: false), so raw HTML in markdown passes through unchanged.
  • Lines 88–91: new DOMParser().parseFromString(html, "text/html") → returns body.childNodes.
  • The file contains no reference to DOMPurify at all .

Code location (repo): src/static/riot/competitions/detail/_tabs.tag lines 373–390 — the visitor-facing competition page:

_.forEach(self.competition.pages, (page, index) => {
  const rendered_content = renderMarkdownWithLatex(page.content)   // line 373
  $(`#page_${index}`)[0].innerHTML = ""                            // line 374
  rendered_content.forEach(node => {
    $(`#page_${index}`)[0].appendChild(node.cloneNode(true));      // live DOM insertion
  });
});
// line 381: same for self.competition.terms
// line 389: same for phase.description

Also affected: the organizer page-preview modal in src/static/riot/competitions/editor/_pages.tag lines 162–163.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions