Skip to content

Jupyter notebook renderer emits attacker-controlled CSS classes into unsanitized, unsandboxed output

Moderate
bircni published GHSA-7452-653r-6gp8 Aug 14, 2026

Package

gomod gitea.dev (Go)

Affected versions

>= 1.27.0, <= 1.27.1

Patched versions

1.27.2

Description

modules/markup/jupyter/jupyter.go sets SanitizerDisabled: true but leaves
DisplayInIframe and ContentSandbox at their zero values. It is the only
renderer in the tree that turns the sanitizer off while still rendering into the
main document. Within that unsanitized output it interpolates
notebook.metadata.language_info.name — a value read directly from the file —
into a literal class attribute without routing it through
RenderInternal.FormatWithSafeAttrs.

HTML-escaping prevents closing the attribute, but whitespace is untouched, so
the notebook can choose its own list of CSS classes on a page where nothing
filters them. Combined with Fomantic's .ui.dimmer and Gitea's !important
Tailwind layer, a repository file can replace the entire viewport of a
repository page and intercept clicks on it.

Script execution was not reached. The reason to fix this is not the overlay:
it is that the class attribute is the one place on this path where a security
control was skipped, and there is no sanitizer, no iframe sandbox and no CSP
behind it.

Why this is a boundary failure rather than a cosmetic bug

Gitea has a deliberate mechanism to stop rendered content from choosing CSS
classes. RenderInternal.ProtectSafeAttrs rewrites class="X" into
data-attr-class="<per-render-nonce>:X"; the sanitizer allows data-attr-class;
and finalProcessor.Close converts it back only when the nonce matches. Content
cannot forge the nonce, so only the renderer's own trusted code can emit a class.
orgmode.go uses this correctly:

_ = ctx.RenderInternal.FormatWithSafeAttrs(sb, `<pre><code class="chroma language-%s">`, ...)

The intent is also written down inside the affected file itself, in the
text/html MIME handler:

// To future developers:  don't allow custom CSS classes or attributes,
// because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS.

That rule is enforced for MIME output and missed for the language field.

Details

1. The sanitizer is disabled and the output is not confined.

func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
  return markup.ExternalRendererOptions{
    SanitizerDisabled: true,
  }
}

DisplayInIframe and ContentSandbox are left unset. In modules/markup/render.go
that removes SanitizeReader from the pipeline, and the bytes land inside
<div class="file-view markup jupyter-render"> on the ordinary repository page.

By contrast the openapi-swagger, viewer-3d and asciicast renderers also
disable the sanitizer, but set DisplayInIframe: true and
ContentSandbox: setting.MarkupRenderDefaultSandbox, so their output is confined
to an iframe with no allow-same-origin.

2. Untrusted input reaches a literal class attribute.

The language value comes from notebook.metadata.language_info.name, falling
back to metadata.kernelspec.language, and is interpolated into a
class="chroma language-…" attribute. htmlutil.WriteFormat applies
template.HTMLEscapeString, which handles < > & ' " and leaves whitespace
alone. No input yields a raw ", so no new attribute can be created — but
spaces pass through, which is enough to append arbitrary additional CSS classes.

3. /render/ serves the same content with no CSP.

The /render/ route adds a Content-Security-Policy: sandbox … header only when
the renderer sets ContentSandbox, a field the Jupyter renderer never sets. The
result is a top-level, same-origin, navigable text/html document built from
repository content, with the sanitizer off and no restrictions, reachable
anonymously on public repositories. Repository authorization on the route itself
is sound (private repositories return 404 to unauthorized users).

4. The notebook selects which frontend renderer processes it.

Because the class is built as language-%s from the file, a notebook can declare
"language_info": {"name": "mermaid"} (or math) and hand its cell source to
mermaid.js / KaTeX. Those frontend paths assign markup via innerHTML in the
parent document before moving it into a non-sandboxed iframe. Today mermaid's
securityLevel: 'strict' and KaTeX's trust: false hold, so script execution
was not achieved — but this is the same unsanitized, no-CSP surface, reached from
a file type where nothing signals that a diagram will be drawn.

Impact

Anyone with write access to a repository can make its pages display arbitrary
full-screen content on the Gitea origin, with the real hostname and certificate,
and make the underlying page unusable. Pushed to a shared repository, it affects
every colleague who opens that repository, with no link sent and no unusual
action taken — making fake session-expiry notices, credential-rotation
instructions or outage banners credible because the instance itself serves them.

Two bounds are worth stating explicitly:

  • It does not cross a permission boundary. The attacker needs write access to
    the repository holding the notebook; read-only access is not enough. Existing
    repository permissions are respected.
  • The overlay is text. The cell body passes through chroma, so there is no
    input, button or link. A credential prompt must end in "go to this other host",
    which the victim types by hand.

Remediation

Upgrade to a fixed release. Until then, avoid rendering or serving untrusted
.ipynb files (for example by disabling the Jupyter renderer on instances that
accept notebooks from untrusted contributors).

Reporter

Toprak Yagcioglu. Found and reproduced against a local throwaway
gitea/gitea:1.27.1 container; nothing was tested against any third-party or
production instance. Happy to supply a reproduction and to verify a candidate
patch.

Severity

Moderate

CVE ID

CVE-2026-60008

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Improper Restriction of Rendered UI Layers or Frames

The web application does not restrict or incorrectly restricts frame objects or UI layers that belong to another application or domain, which can lead to user confusion about which interface the user is interacting with. Learn more on MITRE.

Credits