`)
+ assert.Contains(t, result, `In [1]:`)
+ assert.Contains(t, result, `print`)
+ assert.Contains(t, result, `hello`)
+ assert.Contains(t, result, `stream-stdout`)
+ })
+
+ t.Run("Markdown cell with XSS Protection", func(t *testing.T) {
+ input := `{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "source": [
+ "# Title\n",
+ "Some text\n",
+ "[click me](javascript:alert(1))\n",
+ ""
+ ]
+ }
+ ],
+ "metadata": {},
+ "nbformat": 4
+ }`
+
+ var output strings.Builder
+ ctx := markup.NewRenderContext(t.Context())
+ err := r.Render(ctx, strings.NewReader(input), &output)
+
+ assert.NoError(t, err)
+ result := output.String()
+
+ // Assert normal markup still renders correctly
+ assert.Contains(t, result, `
`)
+ assert.Contains(t, result, `Title`)
+ assert.Contains(t, result, `Some text`)
+ assert.Contains(t, result, `click me`)
+
+ // CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped
+ assert.NotContains(t, result, `javascript:alert`)
+ assert.NotContains(t, result, `
"
+ ]
+ },
+ "metadata": {}
+ }
+ ]
+ }
+ ]
+ }`
+
+ var output strings.Builder
+ ctx := markup.NewRenderContext(t.Context())
+ ctx.RenderOptions.MarkupType = "jupyter-render"
+ err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output)
+ assert.NoError(t, err)
+ const expected = `
+
`
+ assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String()))
+}
diff --git a/modules/test/utils.go b/modules/test/utils.go
index 5a4e13b4232..12b35f42f7a 100644
--- a/modules/test/utils.go
+++ b/modules/test/utils.go
@@ -12,12 +12,16 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "regexp"
+ "slices"
"strconv"
"strings"
"sync"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
+
+ "golang.org/x/net/html"
)
// RedirectURL returns the redirect URL of a http response.
@@ -182,3 +186,48 @@ func ExternalServiceHTTP(t TestingT, envVarName, def string) string {
}
return val
}
+
+var normalizeHTMLSpacesRegexp = sync.OnceValue(func() (ret struct {
+ afterRt, beforeLt *regexp.Regexp
+},
+) {
+ ret.afterRt = regexp.MustCompile(`>\s*`)
+ ret.beforeLt = regexp.MustCompile(`\s*<`)
+ return ret
+})
+
+func NormalizeHTMLSpaces(s string) string {
+ vars := normalizeHTMLSpacesRegexp()
+ s = vars.afterRt.ReplaceAllString(s, ">\n")
+ s = vars.beforeLt.ReplaceAllString(s, "\n<")
+ return strings.TrimSpace(s)
+}
+
+func NormalizeHTMLAttributes(t TestingT, s string) string {
+ nodes, err := html.Parse(strings.NewReader(s))
+ if err != nil {
+ t.Errorf("failed to parse expected HTML: %v", err)
+ return ""
+ }
+
+ var normalize func(n *html.Node)
+ normalize = func(n *html.Node) {
+ slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
+ if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
+ return cmp
+ }
+ if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
+ return cmp
+ }
+ return strings.Compare(a.Val, b.Val)
+ })
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ normalize(c)
+ }
+ }
+ var sb strings.Builder
+ if err = html.Render(&sb, nodes); err != nil {
+ t.Errorf("failed to render HTML: %v", err)
+ }
+ return sb.String()
+}
diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go
index cefe5592c4a..fc900e5a3de 100644
--- a/tests/integration/html_helper.go
+++ b/tests/integration/html_helper.go
@@ -5,13 +5,12 @@ package integration
import (
"io"
- "slices"
- "strings"
"testing"
+ "gitea.dev/modules/test"
+
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
- "golang.org/x/net/html"
)
// HTMLDoc struct
@@ -53,36 +52,10 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string
func assertHTMLEq(t testing.TB, expected, actual string) {
t.Helper()
- if expected == actual {
+ if expected == actual { // fast path
return
}
- exp, err := html.Parse(strings.NewReader(expected))
- if !assert.NoError(t, err) {
- return
- }
- act, err := html.Parse(strings.NewReader(actual))
- if !assert.NoError(t, err) {
- return
- }
- var normalize func(n *html.Node)
- normalize = func(n *html.Node) {
- slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
- if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
- return cmp
- }
- if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
- return cmp
- }
- return strings.Compare(a.Val, b.Val)
- })
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- normalize(c)
- }
- }
- normalize(exp)
- normalize(act)
- var expNormalized, actNormalized strings.Builder
- assert.NoError(t, html.Render(&expNormalized, exp))
- assert.NoError(t, html.Render(&actNormalized, act))
- assert.Equal(t, expNormalized.String(), actNormalized.String())
+ exp := test.NormalizeHTMLAttributes(t, expected)
+ act := test.NormalizeHTMLAttributes(t, actual)
+ assert.Equal(t, exp, act)
}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 2d3e118825d..71e58e7c8ec 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -52,6 +52,7 @@
@import "./markup/content.css";
@import "./markup/codeblock.css";
@import "./markup/codepreview.css";
+@import "./markup/jupyter.css";
@import "./font_i18n.css";
@import "./base.css";
diff --git a/web_src/css/markup/jupyter.css b/web_src/css/markup/jupyter.css
new file mode 100644
index 00000000000..ab128e64a77
--- /dev/null
+++ b/web_src/css/markup/jupyter.css
@@ -0,0 +1,93 @@
+.markup.jupyter-render {
+ padding: 0;
+}
+
+.markup .jupyter-notebook {
+ padding: 20px;
+ background: var(--color-body);
+ border-bottom-left-radius: var(--border-radius);
+ border-bottom-right-radius: var(--border-radius);
+ font-family: var(--fonts-monospace);
+ display: flex;
+ flex-direction: column;
+ gap: 2em;
+}
+
+/* cell code */
+.markup .jupyter-notebook .cell-line {
+ display: flex;
+ width: 100%;
+ gap: 0.5em;
+}
+
+.markup .jupyter-notebook .cell-left {
+ width: 100px;
+ flex-shrink: 0;
+}
+
+.markup .jupyter-notebook .cell-right {
+ flex: 1;
+}
+
+.markup .jupyter-notebook .cell-prompt {
+ padding: 10px 0;
+ color: var(--color-text-light-2);
+ font-size: 13px;
+}
+
+.markup .jupyter-notebook .cell-left.cell-prompt {
+ padding-left: 10px;
+ text-align: right;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.markup .jupyter-notebook .cell-right.cell-prompt {
+ padding-right: 10px;
+}
+
+.markup .jupyter-notebook .cell-input,
+.markup .jupyter-notebook .cell-output {
+ overflow-x: auto;
+}
+
+.markup .jupyter-notebook .cell-input pre,
+.markup .jupyter-notebook .cell-output pre {
+ padding: 10px 16px;
+ font-size: 13px;
+ min-height: 40px;
+ margin: 0;
+}
+
+.markup .jupyter-notebook .cell-input pre {
+ background-color: var(--color-code-bg);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.markup .jupyter-notebook .cell-output {
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+}
+
+.markup .jupyter-notebook .cell-type-code {
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+}
+
+.markup .jupyter-notebook .cell-output-unsupported {
+ color: var(--color-text-light-2);
+ font-style: italic;
+ font-size: 13px;
+}
+
+.markup .jupyter-notebook .cell-output-error {
+ color: var(--color-red);
+}
+
+/* cell markdown */
+.markup .jupyter-notebook .cell-right .embedded-markdown {
+ padding: 0 16px; /* match cell code right padding */
+}