internal/httpd/math_test.go
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><b>&</b></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" onmouseover="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}