internal/httpd/math_test.go

v1.42.0
gitbay/internal/httpd/math_test.go history · blame · raw

259 lines · 11053 bytes

  1package httpd
  2
  3import (
  4	"net/http/httptest"
  5	"regexp"
  6	"strings"
  7	"testing"
  8	"time"
  9
 10	"gitbay.org/gitbay/internal/config"
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14func TestMarkdownMath(t *testing.T) {
 15	cases := []struct {
 16		name, src string
 17		want      []string
 18		not       []string
 19	}{
 20		{"inline", "Euler: $e^{i\\pi}+1=0$.", []string{`<math><msup><mi>e</mi>`, `</math>.`}, nil},
 21		{"display inline", "so $$x^2$$ here", []string{`<math display="block"><msup>`}, nil},
 22		{"block", "text\n$$\n\\frac{a}{b}\n$$\nafter\n", []string{`<math display="block"><mfrac>`, `<p>after</p>`}, []string{"$$"}},
 23		{"one-line block", "$$x_1$$\n", []string{`<math display="block"><msub>`}, []string{"<p>"}},
 24		{"prices", "costs $5 and $10 today", []string{"costs $5 and $10 today"}, []string{"<math"}},
 25		{"space after open", "a $ x$ b", []string{"a $ x$ b"}, []string{"<math"}},
 26		{"space before close", "a $x $ b", []string{"a $x $ b"}, []string{"<math"}},
 27		{"digit after close", "$x$5", []string{"$x$5"}, []string{"<math"}},
 28		{"escaped dollars", `\$x\$`, []string{"$x$"}, []string{"<math"}},
 29		{"escaped dollar inside", `$a\$b$`, []string{`<mo>$</mo>`}, nil},
 30		{"code span", "`$x^2$`", []string{"<code>$x^2$</code>"}, []string{"<math"}},
 31		{"fenced code", "```\n$x^2$\n$$\ny\n$$\n```\n", []string{"$x^2$", "$$\ny\n$$"}, []string{"<math"}},
 32		{"indented code", "    $x$\n", []string{"$x$"}, []string{"<math"}},
 33		{"invalid inline", `see $\frac{a$ here`, []string{`see $\frac{a$ here`}, []string{"<math"}},
 34		{"invalid block", "$$\n\\frac{\n$$\n", []string{"<pre tabindex=\"0\">$$\n\\frac{\n$$</pre>"}, []string{"<math"}},
 35		{"unclosed block", "$$\nx\n", []string{"<p>$$\nx</p>"}, []string{"<math", "<pre"}},
 36		{"blank line ends block", "$$\nx\n\ny $$\n", []string{"<p>$$\nx</p>", "<p>y $$</p>"}, []string{"<math", "<pre"}},
 37		{"block keeps source", "$$\na\nb $$\n", []string{`<math display="block"><mi>a</mi><mi>b</mi></math>`}, nil},
 38		{"markup is escaped", "$\\text{<b>&</b>}$", []string{`<mtext>&lt;b&gt;&amp;&lt;/b&gt;</mtext>`}, []string{"<b>"}},
 39	}
 40	for _, c := range cases {
 41		out := string(ugcHTML(c.src, "md"))
 42		for _, w := range c.want {
 43			if !strings.Contains(out, w) {
 44				t.Errorf("%s: lacks %q:\n%s", c.name, w, out)
 45			}
 46		}
 47		for _, w := range c.not {
 48			if strings.Contains(out, w) {
 49				t.Errorf("%s: has %q:\n%s", c.name, w, out)
 50			}
 51		}
 52	}
 53}
 54
 55func TestOrgMath(t *testing.T) {
 56	cases := []struct {
 57		name, src string
 58		want      []string
 59		not       []string
 60	}{
 61		{"dollar", "Euler: $e^x$ here", []string{`<math><msup><mi>e</mi><mi>x</mi></msup></math> here`}, nil},
 62		{"paren", `a \(x_1\) b`, []string{`<math><msub>`}, []string{`\(`}},
 63		{"bracket", `a \[x^2\] b`, []string{`<math display="block"><msup>`}, []string{`\[`}},
 64		{"double dollar", `a $$x$$ b`, []string{`<math display="block"><mi>x</mi></math>`}, nil},
 65		{"environment block", "\\begin{equation}\nx = \\frac{1}{2}\n\\end{equation}\n", []string{`<math display="block"><mrow><mi>x</mi><mo>=</mo><mfrac>`}, []string{`\begin`}},
 66		{"matrix block", "\\begin{pmatrix}\na & b \\\\\nc & d\n\\end{pmatrix}\n", []string{`<mtable><mtr><mtd><mi>a</mi></mtd>`}, nil},
 67		{"prices", "costs $5 and $10 today", []string{"costs $5 and $10 today"}, []string{"<math"}},
 68		{"verbatim", "=$x^2$= and ~$y$~", []string{"<code>$x^2$</code>", "<code>$y$</code>"}, []string{"<math"}},
 69		{"src block", "#+begin_src tex\n$x^2$\n#+end_src\n", []string{"<pre"}, []string{"<math"}},
 70		{"invalid", `see \(\frac{a\) here`, []string{`see \(\frac{a\) here`}, []string{"<math"}},
 71		{"invalid block", "\\begin{tabular}\nx\n\\end{tabular}\n", []string{`<pre tabindex="0">\begin{tabular}`}, []string{"<math"}},
 72	}
 73	for _, c := range cases {
 74		out := string(ugcHTML(c.src, "org"))
 75		for _, w := range c.want {
 76			if !strings.Contains(out, w) {
 77				t.Errorf("%s: lacks %q:\n%s", c.name, w, out)
 78			}
 79		}
 80		for _, w := range c.not {
 81			if strings.Contains(out, w) {
 82				t.Errorf("%s: has %q:\n%s", c.name, w, out)
 83			}
 84		}
 85	}
 86}
 87
 88// mathPolicy admits the MathML texmath writes and nothing else.
 89func TestMathPolicy(t *testing.T) {
 90	hostile := `<math display="block" xmlns:xlink="http://www.w3.org/1999/xlink" style="x" onclick="x()">` +
 91		`<mi href="javascript:alert(1)" xlink:href="javascript:alert(2)" mathvariant="bold" style="color:red" onmouseover="x()">x</mi>` +
 92		`<mo stretchy="true" form="prefix">(</mo><mspace width="expression(alert(3))"></mspace><mspace width="1em"></mspace>` +
 93		`<semantics><annotation-xml encoding="text/html"><img src=x onerror="alert(4)"></annotation-xml></semantics>` +
 94		`<maction actiontype="statusline"><mi>y</mi></maction><mstyle mathcolor="red"><mi>z</mi></mstyle>` +
 95		`<mtext><style>*{}</style><script>alert(5)</script></mtext></math><math display="inline"></math>`
 96	out := mathPolicy.Sanitize(hostile)
 97	for _, bad := range []string{"href", "xlink", "style", "onclick", "onmouseover", "onerror", "javascript",
 98		"annotation", "semantics", "maction", "mstyle", "mathcolor", "script", "expression",
 99		`mathvariant="bold"`, `stretchy="true"`, "form=", `display="inline"`} {
100		if strings.Contains(out, bad) {
101			t.Errorf("sanitized MathML keeps %q:\n%s", bad, out)
102		}
103	}
104	for _, good := range []string{`<math display="block">`, `<mspace width="1em">`, "<mi>x</mi>", "<mi>y</mi>"} {
105		if !strings.Contains(out, good) {
106			t.Errorf("sanitized MathML lacks %q:\n%s", good, out)
107		}
108	}
109}
110
111// Hostile TeX is refused and shown as escaped source, in bounded time and
112// size, on both syntaxes.
113func TestHostileMath(t *testing.T) {
114	long := strings.Repeat(`x+`, 1<<19) // 1 MiB in one expression
115	deep := strings.Repeat("{", 50000) + "x" + strings.Repeat("}", 50000)
116	for _, tex := range []string{
117		`\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`, `\style{color:red}{x}`,
118		`\color{red" onmouseover="alert(1)}{x}`, `\class{a"b}{x}`, `\def\a{\a\a}\a`,
119		`\text{</math><script>alert(1)</script>}`, `\text{<img src=x onerror=alert(1)>}`,
120		deep, long, strings.Repeat(`\sqrt{`, 10000) + "x",
121	} {
122		for _, doc := range []struct{ src, format string }{
123			{"$" + tex + "$", "md"}, {"$$\n" + tex + "\n$$\n", "md"},
124			{`\(` + tex + `\)`, "org"}, {"\\[" + tex + "\\]", "org"},
125		} {
126			start := time.Now()
127			out := string(ugcHTML(doc.src, doc.format))
128			if d := time.Since(start); d > 2*time.Second {
129				t.Errorf("%.30q (%s) took %v", tex, doc.format, d)
130			}
131			if len(out) > 8*len(doc.src)+1024 {
132				t.Errorf("%.30q (%s): %d bytes out for %d in", tex, doc.format, len(out), len(doc.src))
133			}
134			for _, bad := range []string{"<script", "<img", `href="`, `onmouseover="`, `style="`, `class="a`} {
135				if strings.Contains(out, bad) {
136					t.Errorf("%.30q (%s) emits %q:\n%.300s", tex, doc.format, bad, out)
137				}
138			}
139		}
140	}
141	// A refused \text keeps its payload as visible text, escaped.
142	out := string(ugcHTML(`$\href{javascript:alert(1)}{x}$`, "md"))
143	if !strings.Contains(out, `$\href{javascript:alert(1)}{x}$`) || strings.Contains(out, "<math") || strings.Contains(out, "<a") {
144		t.Errorf("refused \\href: %s", out)
145	}
146}
147
148// The issue page renders its body's math through the shared path, and
149// autolinking leaves text inside <math> alone.
150func TestIssuePageRendersMath(t *testing.T) {
151	st, err := store.Open(":memory:")
152	if err != nil {
153		t.Fatal(err)
154	}
155	defer st.Close()
156	if err := st.MigrateUp(); err != nil {
157		t.Fatal(err)
158	}
159	uid, err := st.CreateUser("alice", false)
160	if err != nil {
161		t.Fatal(err)
162	}
163	repoID, err := st.CreateRepo("user", uid, "app", "public")
164	if err != nil {
165		t.Fatal(err)
166	}
167	if _, err := st.CreateIssue(repoID, uid, "math", `Area is $\pi r^2$, see $\text{#1}$.`, "md"); err != nil {
168		t.Fatal(err)
169	}
170	cfg := config.Default()
171	cfg.Web.Mode = "accounts"
172	s := New(cfg, st, nil)
173	rr := httptest.NewRecorder()
174	s.Handler().ServeHTTP(rr, httptest.NewRequest("GET", "/alice/app/issues/1", nil))
175	if rr.Code != 200 {
176		t.Fatalf("status %d", rr.Code)
177	}
178	body := rr.Body.String()
179	if !strings.Contains(body, `<math><mi>π</mi><msup><mi>r</mi><mn>2</mn></msup></math>`) {
180		t.Errorf("no MathML in the issue page:\n%s", body)
181	}
182	if !strings.Contains(body, `<mtext>#1</mtext>`) {
183		t.Errorf("autolink rewrote text inside <math>:\n%s", body)
184	}
185}
186
187// MathML written by hand is user HTML, and ugcPolicy strips it: only the
188// converter's output, through mathPolicy, reaches the page.
189func TestRawMathMLStripped(t *testing.T) {
190	raw := `<math display="block"><mi>q</mi></math>`
191	for name, out := range map[string]string{
192		"html readme":   string(renderReadme("README.html", []byte(raw))),
193		"markdown html": string(renderReadme("README.md", []byte("para "+raw+"\n\n"+raw+"\n"))),
194		"org export":    string(renderReadme("README.org", []byte("#+begin_export html\n"+raw+"\n#+end_export\n"))),
195		"org inline":    string(renderReadme("README.org", []byte("@@html:"+raw+"@@\n"))),
196	} {
197		if strings.Contains(out, "<math") || strings.Contains(out, "<mi>") {
198			t.Errorf("%s keeps raw MathML:\n%s", name, out)
199		}
200	}
201	for name, out := range map[string]string{
202		"markdown": string(renderReadme("README.md", []byte("$q$\n"))),
203		"org":      string(renderReadme("README.org", []byte("$q$\n"))),
204	} {
205		if !strings.Contains(out, "<math><mi>q</mi></math>") {
206			t.Errorf("%s: no MathML:\n%s", name, out)
207		}
208	}
209}
210
211// Org placeholders: a document cannot spell one, and one that lands in an
212// attribute is filled with escaped source, not markup.
213func TestMathSlots(t *testing.T) {
214	out := string(ugcHTML("gitbaymath0z $x$ gitbaymath00000000000000000000000000n0z", "org"))
215	if strings.Count(out, "<math>") != 1 || !strings.Contains(out, "gitbaymath0z") {
216		t.Errorf("forged placeholder: %s", out)
217	}
218	m := newMathSlots()
219	a := m.put(`<math><mi>x</mi></math>`, `$x" onmouseover="y$`)
220	b := m.put(`<math><mi>y</mi></math>`, `$y$`)
221	got := m.fill(`<a title="` + a + `">` + b + `</a>`)
222	want := `<a title="$x&#34; onmouseover=&#34;y$"><math><mi>y</mi></math></a>`
223	if got != want {
224		t.Errorf("fill:\n got %s\nwant %s", got, want)
225	}
226	if other := newMathSlots(); other.prefix == m.prefix {
227		t.Error("placeholder prefix repeats across renders")
228	}
229}
230
231// Many openers without closers on one line scan in linear time, and the
232// per-document budget leaves later math as text.
233func TestMathLinear(t *testing.T) {
234	for _, src := range []string{
235		strings.Repeat("$a ", 1<<20/3),
236		strings.Repeat("$$a ", 1<<20/4),
237		strings.Repeat("$$\na\n", 1<<20/5),
238	} {
239		for _, format := range []string{"md", "org"} {
240			start := time.Now()
241			ugcHTML(src, format)
242			if d := time.Since(start); d > 2*time.Second {
243				t.Errorf("%s: %.12q x %d took %v", format, src[:4], len(src), d)
244			}
245		}
246	}
247	src := strings.Repeat("$x$ ", maxMathExprs+10)
248	out := string(ugcHTML(src, "md"))
249	if n := strings.Count(out, "<math>"); n != maxMathExprs {
250		t.Errorf("markdown rendered %d expressions, budget %d", n, maxMathExprs)
251	}
252	out = string(ugcHTML(src, "org"))
253	if n := strings.Count(out, "<math>"); n != maxMathExprs {
254		t.Errorf("org rendered %d expressions, budget %d", n, maxMathExprs)
255	}
256	if regexp.MustCompile(`gitbaymath[0-9a-f]+n`).MatchString(out) {
257		t.Error("a placeholder survived")
258	}
259}