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.
modules/markup/jupyter/jupyter.gosetsSanitizerDisabled: truebut leavesDisplayInIframeandContentSandboxat their zero values. It is the onlyrenderer 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
classattribute without routing it throughRenderInternal.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.dimmerand Gitea's!importantTailwind 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.ProtectSafeAttrsrewritesclass="X"intodata-attr-class="<per-render-nonce>:X"; the sanitizer allowsdata-attr-class;and
finalProcessor.Closeconverts it back only when the nonce matches. Contentcannot forge the nonce, so only the renderer's own trusted code can emit a class.
orgmode.gouses this correctly:The intent is also written down inside the affected file itself, in the
text/htmlMIME handler: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.
DisplayInIframeandContentSandboxare left unset. Inmodules/markup/render.gothat removes
SanitizeReaderfrom 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-3dandasciicastrenderers alsodisable the sanitizer, but set
DisplayInIframe: trueandContentSandbox: setting.MarkupRenderDefaultSandbox, so their output is confinedto an iframe with no
allow-same-origin.2. Untrusted input reaches a literal
classattribute.The
languagevalue comes fromnotebook.metadata.language_info.name, fallingback to
metadata.kernelspec.language, and is interpolated into aclass="chroma language-…"attribute.htmlutil.WriteFormatappliestemplate.HTMLEscapeString, which handles< > & ' "and leaves whitespacealone. No input yields a raw
", so no new attribute can be created — butspaces pass through, which is enough to append arbitrary additional CSS classes.
3.
/render/serves the same content with no CSP.The
/render/route adds aContent-Security-Policy: sandbox …header only whenthe renderer sets
ContentSandbox, a field the Jupyter renderer never sets. Theresult is a top-level, same-origin, navigable
text/htmldocument built fromrepository 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-%sfrom the file, a notebook can declare"language_info": {"name": "mermaid"}(ormath) and hand its cell source tomermaid.js / KaTeX. Those frontend paths assign markup via
innerHTMLin theparent document before moving it into a non-sandboxed iframe. Today mermaid's
securityLevel: 'strict'and KaTeX'strust: falsehold, so script executionwas 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:
the repository holding the notebook; read-only access is not enough. Existing
repository permissions are respected.
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
.ipynbfiles (for example by disabling the Jupyter renderer on instances thataccept notebooks from untrusted contributors).
Reporter
Toprak Yagcioglu. Found and reproduced against a local throwaway
gitea/gitea:1.27.1container; nothing was tested against any third-party orproduction instance. Happy to supply a reproduction and to verify a candidate
patch.