app/escape_test.go
189 lines · 7191 bytes
1package app
2
3import (
4 "html/template"
5 "net/http/httptest"
6 "skunkyart/static"
7 "strings"
8 "sync"
9 "testing"
10
11 "github.com/krazywarez/devianter"
12)
13
14var loadTemplatesOnce sync.Once
15
16// loadTemplates makes static.Templates usable from a test. The non-embed build
17// reads the repository's static/ directory; the embed build already has it.
18func loadTemplates() {
19 loadTemplatesOnce.Do(func() {
20 static.StaticPath = "../static"
21 static.CopyTemplatesToMemory()
22 LoadLanguages()
23 ParseTemplates()
24 })
25}
26
27// markup is the payload every escaping test injects. It closes an attribute,
28// closes a tag and opens a new element, which is what an injection needs to do.
29const markup = `"><b id=injected>x</b>`
30
31// TestEveryPageTemplateRenders pins down that the switch to html/template
32// parses and executes every page: html/template rejects some constructs
33// text/template accepts, and a failure here would be a 500 on every request.
34func TestEveryPageTemplateRenders(t *testing.T) {
35 loadTemplates()
36 for _, page := range []string{"about.htm", "daily.htm", "deviantion.htm", "gruser.htm", "search.htm"} {
37 rec := httptest.NewRecorder()
38 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/"}
39 s.ExecuteTemplate(page, "html", &s)
40 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "</html>") {
41 t.Errorf("%s: status %d, body %q", page, rec.Code, rec.Body.String())
42 }
43 }
44
45 rec := httptest.NewRecorder()
46 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Lang: "en"}
47 s.ExecuteTemplate("index.htm", "html", &s)
48 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "</html>") || !strings.Contains(rec.Body.String(), `lang="en"`) {
49 t.Errorf("index.htm: status %d, body %q", rec.Code, rec.Body.String())
50 }
51}
52
53// TestSearchPageEscapesTheQuery is the regression test for the reflected
54// query: it appears in the search box's value attribute and in the results
55// heading, and both must show it as text.
56func TestSearchPageEscapesTheQuery(t *testing.T) {
57 loadTemplates()
58 rec := httptest.NewRecorder()
59 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Endpoint: "search", QueryRaw: markup}
60 s.Templates.Search.List = template.HTML("<div></div>")
61 s.Templates.Search.Content.Total = 1
62 s.ExecuteTemplate("search.htm", "html", &s)
63
64 body := rec.Body.String()
65 if strings.Contains(body, "<b id=injected>") {
66 t.Fatalf("query rendered as markup:\n%s", body)
67 }
68 if n := strings.Count(body, "<b id=injected>"); n != 3 {
69 t.Errorf("escaped query appears %d times, want 3 (title, value attribute, heading):\n%s", n, body)
70 }
71}
72
73// TestDeviationListEscapesTitles covers the Go-built listing, which
74// html/template cannot escape because it arrives as template.HTML.
75func TestDeviationListEscapesTitles(t *testing.T) {
76 nsfw := CFG.Nsfw
77 CFG.Nsfw = true
78 defer func() { CFG.Nsfw = nsfw }()
79
80 d := devianter.Deviation{Title: markup}
81 d.Author.Username = markup
82 devs := []devianter.Deviation{d}
83
84 out := skunkyart{Host: "http://localhost"}.DeviationList(devs, false)
85 if strings.Contains(out, "<b id=injected>") || !strings.Contains(out, "<b id=injected>") {
86 t.Errorf("HTML listing did not escape the title:\n%s", out)
87 }
88
89 rec := httptest.NewRecorder()
90 skunkyart{Host: "http://localhost", Writer: rec, Atom: true}.DeviationList(devs, true)
91 if feed := rec.Body.String(); strings.Contains(feed, "<b id=injected>") || !strings.Contains(feed, "<b id=injected>") {
92 t.Errorf("Atom feed did not escape the title:\n%s", feed)
93 }
94}
95
96// TestParseCommentsEscapesUsernames covers the comment thread, where the name
97// is written as link text and as the "In reply to" target.
98func TestParseCommentsEscapesUsernames(t *testing.T) {
99 var c devianter.Comments
100 var parent, reply devianter.Thread
101 parent.ID = 1
102 parent.User.Username = markup
103 reply.ID = 2
104 reply.Parent = 1
105 reply.User.Username = "bob"
106 c.Thread = []devianter.Thread{parent, reply}
107
108 out := skunkyart{Host: "http://localhost", _pth: "/post/x/y"}.ParseComments(c, devianter.Error{})
109 if strings.Contains(out, "<b id=injected>") {
110 t.Fatalf("username rendered as markup:\n%s", out)
111 }
112 if n := strings.Count(out, "<b id=injected>"); n != 5 {
113 t.Errorf("escaped username appears %d times, want 5 (avatar src and alt, link, author, reply target):\n%s", n, out)
114 }
115}
116
117// TestParseDescriptionEscapesMarkupText covers the plain-HTML branch: text is
118// escaped, whitelisted tags are kept bare, and anything else is dropped.
119func TestParseDescriptionEscapesMarkupText(t *testing.T) {
120 var d devianter.Text
121 d.Html.Markup = `a <b class="z">b</b> <script>alert(1)</script> <i>`
122
123 out := ParseDescription("http://localhost", d)
124 for _, bad := range []string{"<script>", `class="z"`, "<i>"} {
125 if strings.Contains(out, bad) {
126 t.Errorf("output contains %q:\n%s", bad, out)
127 }
128 }
129 for _, want := range []string{"<b>b</b>", "<i>"} {
130 if !strings.Contains(out, want) {
131 t.Errorf("output lacks %q:\n%s", want, out)
132 }
133 }
134}
135
136// TestErrorPageShowsOneEscapedLine covers the 502 page: a WAF block arrives
137// as a whole HTML document, and only its first line is echoed, as text.
138func TestErrorPageShowsOneEscapedLine(t *testing.T) {
139 rec := httptest.NewRecorder()
140 skunkyart{Writer: rec, Host: "http://localhost"}.Error(devianter.Error{Error: "blocked <!DOCTYPE html>\n<html>second line"})
141
142 body := rec.Body.String()
143 if rec.Code != 502 {
144 t.Errorf("status %d, want 502", rec.Code)
145 }
146 if strings.Contains(body, "second line") {
147 t.Errorf("error page carries lines past the first:\n%s", body)
148 }
149 if strings.Contains(body, "<!DOCTYPE html>") || !strings.Contains(body, "<!DOCTYPE html>") {
150 t.Errorf("upstream error not escaped:\n%s", body)
151 }
152}
153
154// TestExecuteTemplateUsesTheRequestLanguage pins that the per-language parsed
155// sets answer with the right catalogue.
156func TestExecuteTemplateUsesTheRequestLanguage(t *testing.T) {
157 loadTemplates()
158 rec := httptest.NewRecorder()
159 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Lang: "es"}
160 s.ExecuteTemplate("about.htm", "html", &s)
161 if !strings.Contains(rec.Body.String(), "Ajustes de la instancia") {
162 t.Errorf("Spanish request rendered without the Spanish catalogue:\n%s", rec.Body.String())
163 }
164}
165
166// TestListingImagesCarryAltText covers the accessibility fix: every image the
167// Go builders emit names what it shows.
168func TestListingImagesCarryAltText(t *testing.T) {
169 nsfw := CFG.Nsfw
170 CFG.Nsfw = true
171 defer func() { CFG.Nsfw = nsfw }()
172
173 d := *fullviewDeviation()
174 d.Title = "T"
175 d.Author.Username = "alice"
176 if out := (skunkyart{Host: "http://localhost"}).DeviationList([]devianter.Deviation{d}, false); !strings.Contains(out, `alt="alice - T"`) {
177 t.Errorf("listing image has no alt text:\n%s", out)
178 }
179 if out := BuildUserPlate("http://localhost", "bob"); !strings.Contains(out, `alt="bob"`) {
180 t.Errorf("user plate image has no alt text:\n%s", out)
181 }
182 var c devianter.Comments
183 var th devianter.Thread
184 th.User.Username = "carol"
185 c.Thread = []devianter.Thread{th}
186 if out := (skunkyart{Host: "http://localhost"}).ParseComments(c, devianter.Error{}); !strings.Contains(out, `alt="carol"`) {
187 t.Errorf("comment avatar has no alt text:\n%s", out)
188 }
189}