app/escape_test.go

main
skunky-art/app/escape_test.go history · blame · raw

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, "&lt;b id=injected&gt;"); 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, "&lt;b id=injected&gt;") {
 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, "&lt;b id=injected&gt;") {
 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, "&lt;b id=injected&gt;"); 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> &lt;i&gt;`
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>", "&lt;i&gt;"} {
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, "&lt;!DOCTYPE html&gt;") {
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}