Commit 35d16226b9

35d16226b9a4b911c0abf3cf7305b28336028d84

parent: 91ac29cc4c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 02:31 UTC

Escape everything rendered from user or DeviantArt input

Templates now use html/template, so query strings, usernames and titles
are escaped in context. The Go-built fragments (listings, comments,
descriptions, folders, tags, strips, user plates, pagination) escape
each DeviantArt-supplied string themselves and are handed to the
template as template.HTML. The Atom feed shares the same escaping.
ParseDescription's HTML branch emits whitelisted tags bare rather than
with their original attributes.

The 502 page shows only the first line of the upstream error, escaped,
instead of echoing a whole WAF block page.

Closes #13
Closes #19

Layout: unified · split

app/escape_test.go added +150
@@ -0,0 +1,150 @@
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 })
23}
24
25// markup is the payload every escaping test injects. It closes an attribute,
26// closes a tag and opens a new element, which is what an injection needs to do.
27const markup = `"><b id=injected>x</b>`
28
29// TestEveryPageTemplateRenders pins down that the switch to html/template
30// parses and executes every page: html/template rejects some constructs
31// text/template accepts, and a failure here would be a 500 on every request.
32func TestEveryPageTemplateRenders(t *testing.T) {
33 loadTemplates()
34 for _, page := range []string{"about.htm", "daily.htm", "deviantion.htm", "gruser.htm", "search.htm"} {
35 rec := httptest.NewRecorder()
36 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/"}
37 s.ExecuteTemplate(page, "html", &s)
38 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "</html>") {
39 t.Errorf("%s: status %d, body %q", page, rec.Code, rec.Body.String())
40 }
41 }
42
43 rec := httptest.NewRecorder()
44 uri := "/"
45 skunkyart{Writer: rec}.ExecuteTemplate("index.htm", "html", &uri)
46 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "</html>") {
47 t.Errorf("index.htm: status %d, body %q", rec.Code, rec.Body.String())
48 }
49}
50
51// TestSearchPageEscapesTheQuery is the regression test for the reflected
52// query: it appears in the search box's value attribute and in the results
53// heading, and both must show it as text.
54func TestSearchPageEscapesTheQuery(t *testing.T) {
55 loadTemplates()
56 rec := httptest.NewRecorder()
57 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Endpoint: "search", QueryRaw: markup}
58 s.Templates.Search.List = template.HTML("<div></div>")
59 s.Templates.Search.Content.Total = 1
60 s.ExecuteTemplate("search.htm", "html", &s)
61
62 body := rec.Body.String()
63 if strings.Contains(body, "<b id=injected>") {
64 t.Fatalf("query rendered as markup:\n%s", body)
65 }
66 if n := strings.Count(body, "&lt;b id=injected&gt;"); n != 3 {
67 t.Errorf("escaped query appears %d times, want 3 (title, value attribute, heading):\n%s", n, body)
68 }
69}
70
71// TestDeviationListEscapesTitles covers the Go-built listing, which
72// html/template cannot escape because it arrives as template.HTML.
73func TestDeviationListEscapesTitles(t *testing.T) {
74 nsfw := CFG.Nsfw
75 CFG.Nsfw = true
76 defer func() { CFG.Nsfw = nsfw }()
77
78 d := devianter.Deviation{Title: markup}
79 d.Author.Username = markup
80 devs := []devianter.Deviation{d}
81
82 out := skunkyart{Host: "http://localhost"}.DeviationList(devs, false)
83 if strings.Contains(out, "<b id=injected>") || !strings.Contains(out, "&lt;b id=injected&gt;") {
84 t.Errorf("HTML listing did not escape the title:\n%s", out)
85 }
86
87 rec := httptest.NewRecorder()
88 skunkyart{Host: "http://localhost", Writer: rec, Atom: true}.DeviationList(devs, true)
89 if feed := rec.Body.String(); strings.Contains(feed, "<b id=injected>") || !strings.Contains(feed, "&lt;b id=injected&gt;") {
90 t.Errorf("Atom feed did not escape the title:\n%s", feed)
91 }
92}
93
94// TestParseCommentsEscapesUsernames covers the comment thread, where the name
95// is written as link text and as the "In reply to" target.
96func TestParseCommentsEscapesUsernames(t *testing.T) {
97 var c devianter.Comments
98 var parent, reply devianter.Thread
99 parent.ID = 1
100 parent.User.Username = markup
101 reply.ID = 2
102 reply.Parent = 1
103 reply.User.Username = "bob"
104 c.Thread = []devianter.Thread{parent, reply}
105
106 out := skunkyart{Host: "http://localhost", _pth: "/post/x/y"}.ParseComments(c, devianter.Error{})
107 if strings.Contains(out, "<b id=injected>") {
108 t.Fatalf("username rendered as markup:\n%s", out)
109 }
110 if n := strings.Count(out, "&lt;b id=injected&gt;"); n != 4 {
111 t.Errorf("escaped username appears %d times, want 4 (avatar, link, author, reply target):\n%s", n, out)
112 }
113}
114
115// TestParseDescriptionEscapesMarkupText covers the plain-HTML branch: text is
116// escaped, whitelisted tags are kept bare, and anything else is dropped.
117func TestParseDescriptionEscapesMarkupText(t *testing.T) {
118 var d devianter.Text
119 d.Html.Markup = `a <b class="z">b</b> <script>alert(1)</script> &lt;i&gt;`
120
121 out := ParseDescription("http://localhost", d)
122 for _, bad := range []string{"<script>", `class="z"`, "<i>"} {
123 if strings.Contains(out, bad) {
124 t.Errorf("output contains %q:\n%s", bad, out)
125 }
126 }
127 for _, want := range []string{"<b>b</b>", "&lt;i&gt;"} {
128 if !strings.Contains(out, want) {
129 t.Errorf("output lacks %q:\n%s", want, out)
130 }
131 }
132}
133
134// TestErrorPageShowsOneEscapedLine covers the 502 page: a WAF block arrives
135// as a whole HTML document, and only its first line is echoed, as text.
136func TestErrorPageShowsOneEscapedLine(t *testing.T) {
137 rec := httptest.NewRecorder()
138 skunkyart{Writer: rec, Host: "http://localhost"}.Error(devianter.Error{Error: "blocked <!DOCTYPE html>\n<html>second line"})
139
140 body := rec.Body.String()
141 if rec.Code != 502 {
142 t.Errorf("status %d, want 502", rec.Code)
143 }
144 if strings.Contains(body, "second line") {
145 t.Errorf("error page carries lines past the first:\n%s", body)
146 }
147 if strings.Contains(body, "<!DOCTYPE html>") || !strings.Contains(body, "&lt;!DOCTYPE html&gt;") {
148 t.Errorf("upstream error not escaped:\n%s", body)
149 }
150}
app/parsers.go +43 −31
@@ -32,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
32 cmmts.WriteString(`"><p id="`) 32 cmmts.WriteString(`"><p id="`)
33 cmmts.WriteString(strconv.Itoa(x.ID)) 33 cmmts.WriteString(strconv.Itoa(x.ID))
34 cmmts.WriteString(`"><img src="`) 34 cmmts.WriteString(`"><img src="`)
35 cmmts.WriteString(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a")) 35 cmmts.WriteString(esc(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a")))
36 cmmts.WriteString(`" width="30px" height="30px"><a href="`) 36 cmmts.WriteString(`" width="30px" height="30px"><a href="`)
37 cmmts.WriteString(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a")) 37 cmmts.WriteString(esc(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a")))
38 cmmts.WriteString(`"><b`) 38 cmmts.WriteString(`"><b`)
39 cmmts.WriteString(` class="`) 39 cmmts.WriteString(` class="`)
40 if x.User.Banned { 40 if x.User.Banned {
@@ -44,19 +44,19 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
44 cmmts.WriteString(`author`) 44 cmmts.WriteString(`author`)
45 } 45 }
46 cmmts.WriteString(`">`) 46 cmmts.WriteString(`">`)
47 cmmts.WriteString(x.User.Username) 47 cmmts.WriteString(esc(x.User.Username))
48 cmmts.WriteString("</b></a> ") 48 cmmts.WriteString("</b></a> ")
49 49
50 if x.Parent > 0 { 50 if x.Parent > 0 {
51 cmmts.WriteString(` In reply to <a href="`) 51 cmmts.WriteString(` In reply to <a href="`)
52 cmmts.WriteString(s._pth) 52 cmmts.WriteString(esc(s._pth))
53 cmmts.WriteString("#") 53 cmmts.WriteString("#")
54 cmmts.WriteString(strconv.Itoa(x.Parent)) 54 cmmts.WriteString(strconv.Itoa(x.Parent))
55 cmmts.WriteString(`">`) 55 cmmts.WriteString(`">`)
56 if replied[x.Parent] == "" { 56 if replied[x.Parent] == "" {
57 cmmts.WriteString("???") 57 cmmts.WriteString("???")
58 } else { 58 } else {
59 cmmts.WriteString(replied[x.Parent]) 59 cmmts.WriteString(esc(replied[x.Parent]))
60 } 60 }
61 cmmts.WriteString("</a>") 61 cmmts.WriteString("</a>")
62 } 62 }
@@ -108,27 +108,31 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
108 if !VisibleDeviation(data) { 108 if !VisibleDeviation(data) {
109 continue 109 continue
110 } 110 }
111 if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); true { 111 // Escaped once here: the same values go into both the HTML grid and the
112 // Atom feed, and html.EscapeString produces entities XML accepts too.
113 author, title := esc(data.Author.Username), esc(data.Title)
114 postURL := esc(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url))
115 if preview, fullview := esc(ParseMedia(s.Host, data.Media, 320)), esc(ParseMedia(s.Host, data.Media)); true {
112 if allowAtom && s.Atom { 116 if allowAtom && s.Atom {
113 s.Writer.Header().Add("Content-Type", "application/atom+xml") 117 s.Writer.Header().Add("Content-Type", "application/atom+xml")
114 id := strconv.Itoa(data.ID) 118 id := strconv.Itoa(data.ID)
115 listContent.WriteString(`<entry><author><name>`) 119 listContent.WriteString(`<entry><author><name>`)
116 listContent.WriteString(data.Author.Username) 120 listContent.WriteString(author)
117 listContent.WriteString(`</name></author><title>`) 121 listContent.WriteString(`</name></author><title>`)
118 listContent.WriteString(data.Title) 122 listContent.WriteString(title)
119 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`) 123 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
120 listContent.WriteString(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id)) 124 listContent.WriteString(esc(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id)))
121 listContent.WriteString(`"/><id>`) 125 listContent.WriteString(`"/><id>`)
122 listContent.WriteString(id) 126 listContent.WriteString(id)
123 listContent.WriteString(`</id><published>`) 127 listContent.WriteString(`</id><published>`)
124 listContent.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700")) 128 listContent.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
125 listContent.WriteString(`</published>`) 129 listContent.WriteString(`</published>`)
126 listContent.WriteString(`<media:group><media:title>`) 130 listContent.WriteString(`<media:group><media:title>`)
127 listContent.WriteString(data.Title) 131 listContent.WriteString(title)
128 listContent.WriteString(`</media:title><media:thumbinal url="`) 132 listContent.WriteString(`</media:title><media:thumbinal url="`)
129 listContent.WriteString(preview) 133 listContent.WriteString(preview)
130 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`) 134 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
131 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url)) 135 listContent.WriteString(postURL)
132 listContent.WriteString(`"><img src="`) 136 listContent.WriteString(`"><img src="`)
133 listContent.WriteString(fullview) 137 listContent.WriteString(fullview)
134 listContent.WriteString(`"/></a><p>`) 138 listContent.WriteString(`"/></a><p>`)
@@ -146,11 +150,11 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
146 listContent.WriteString(`<h1>[ TEXT ]</h1>`) 150 listContent.WriteString(`<h1>[ TEXT ]</h1>`)
147 } 151 }
148 listContent.WriteString(`<br><a href="`) 152 listContent.WriteString(`<br><a href="`)
149 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url)) 153 listContent.WriteString(postURL)
150 listContent.WriteString(`">`) 154 listContent.WriteString(`">`)
151 listContent.WriteString(data.Author.Username) 155 listContent.WriteString(author)
152 listContent.WriteString(" - ") 156 listContent.WriteString(" - ")
153 listContent.WriteString(data.Title) 157 listContent.WriteString(title)
154 158
155 if data.NSFW { 159 if data.NSFW {
156 listContent.WriteString(` [<span class="nsfw">NSFW</span>]`) 160 listContent.WriteString(` [<span class="nsfw">NSFW</span>]`)
@@ -175,14 +179,14 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
175 case s.Type == 0: 179 case s.Type == 0:
176 list.WriteString("Daily Deviations") 180 list.WriteString("Daily Deviations")
177 case s.Type == 'g' && len(devs) != 0: 181 case s.Type == 'g' && len(devs) != 0:
178 list.WriteString(devs[0].Author.Username) 182 list.WriteString(esc(devs[0].Author.Username))
179 default: 183 default:
180 list.WriteString("SkunkyArt") 184 list.WriteString("SkunkyArt")
181 } 185 }
182 list.WriteString(`</title>`) 186 list.WriteString(`</title>`)
183 187
184 list.WriteString(`<link rel="alternate" href="`) 188 list.WriteString(`<link rel="alternate" href="`)
185 list.WriteString(s.Host) 189 list.WriteString(esc(s.Host))
186 list.WriteString(`"/>`) 190 list.WriteString(`"/>`)
187 191
188 list.WriteString(listContent.String()) 192 list.WriteString(listContent.String())
@@ -316,7 +320,7 @@ func ParseDescription(host string, dscr devianter.Text) string {
316 for n := range Styles { 320 for n := range Styles {
317 Styles := &Styles[n] 321 Styles := &Styles[n]
318 Styles.TxtRaw = x.Text[Styles.From:Styles.To] 322 Styles.TxtRaw = x.Text[Styles.From:Styles.To]
319 Styles.Txt = TagBuilder(Styles.TxtRaw, tags[Styles.From*Styles.To]...) 323 Styles.Txt = TagBuilder(esc(Styles.TxtRaw), tags[Styles.From*Styles.To]...)
320 } 324 }
321 } 325 }
322 326
@@ -325,13 +329,13 @@ func ParseDescription(host string, dscr devianter.Text) string {
325 if len(x.EntityRanges) != 0 { 329 if len(x.EntityRanges) != 0 {
326 d := entities[x.EntityRanges[0].Key] 330 d := entities[x.EntityRanges[0].Key]
327 parsedDescription.WriteString(`<a href="`) 331 parsedDescription.WriteString(`<a href="`)
328 parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(host, d.Url)) 332 parsedDescription.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(host, d.Url)))
329 parsedDescription.WriteString(`"><img width="50%" src="`) 333 parsedDescription.WriteString(`"><img width="50%" src="`)
330 parsedDescription.WriteString(ParseMedia(host, d.Media)) 334 parsedDescription.WriteString(esc(ParseMedia(host, d.Media)))
331 parsedDescription.WriteString(`" title="`) 335 parsedDescription.WriteString(`" title="`)
332 parsedDescription.WriteString(d.Author.Username) 336 parsedDescription.WriteString(esc(d.Author.Username))
333 parsedDescription.WriteString(" - ") 337 parsedDescription.WriteString(" - ")
334 parsedDescription.WriteString(d.Title) 338 parsedDescription.WriteString(esc(d.Title))
335 parsedDescription.WriteString(`"></a>`) 339 parsedDescription.WriteString(`"></a>`)
336 } 340 }
337 case "unstyled": 341 case "unstyled":
@@ -342,22 +346,22 @@ func ParseDescription(host string, dscr devianter.Text) string {
342 tag = "h2" 346 tag = "h2"
343 } 347 }
344 348
345 parsedDescription.WriteString(x.Text[:r.From]) 349 parsedDescription.WriteString(esc(x.Text[:r.From]))
346 if len(urls) != 0 && len(x.EntityRanges) != 0 { 350 if len(urls) != 0 && len(x.EntityRanges) != 0 {
347 ra := &x.EntityRanges[0] 351 ra := &x.EntityRanges[0]
348 352
349 parsedDescription.WriteString(`<a target="_blank" href="`) 353 parsedDescription.WriteString(`<a target="_blank" href="`)
350 parsedDescription.WriteString(urls[ra.Key]) 354 parsedDescription.WriteString(esc(urls[ra.Key]))
351 parsedDescription.WriteString(`">`) 355 parsedDescription.WriteString(`">`)
352 parsedDescription.WriteString(r.Txt) 356 parsedDescription.WriteString(r.Txt)
353 parsedDescription.WriteString(`</a>`) 357 parsedDescription.WriteString(`</a>`)
354 } else if l > n+1 { 358 } else if l > n+1 {
355 parsedDescription.WriteString(r.Txt) 359 parsedDescription.WriteString(r.Txt)
356 } 360 }
357 parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:])) 361 parsedDescription.WriteString(TagBuilder(tag, esc(x.Text[r.To:])))
358 } 362 }
359 } else { 363 } else {
360 parsedDescription.WriteString(x.Text) 364 parsedDescription.WriteString(esc(x.Text))
361 } 365 }
362 } 366 }
363 parsedDescription.WriteString("<br>") 367 parsedDescription.WriteString("<br>")
@@ -377,9 +381,9 @@ func ParseDescription(host string, dscr devianter.Text) string {
377 if a.Key == "href" { 381 if a.Key == "href" {
378 url := DeleteTrackingFromURL(a.Val) 382 url := DeleteTrackingFromURL(a.Val)
379 parsedDescription.WriteString(`<a target="_blank" href="`) 383 parsedDescription.WriteString(`<a target="_blank" href="`)
380 parsedDescription.WriteString(url) 384 parsedDescription.WriteString(esc(url))
381 parsedDescription.WriteString(`">`) 385 parsedDescription.WriteString(`">`)
382 parsedDescription.WriteString(GetValueOfTag(tt)) 386 parsedDescription.WriteString(esc(GetValueOfTag(tt)))
383 parsedDescription.WriteString("</a> ") 387 parsedDescription.WriteString("</a> ")
384 } 388 }
385 } 389 }
@@ -397,20 +401,28 @@ func ParseDescription(host string, dscr devianter.Text) string {
397 if title != "" { 401 if title != "" {
398 for x := -1; x < b; x++ { 402 for x := -1; x < b; x++ {
399 parsedDescription.WriteString(`<img src="`) 403 parsedDescription.WriteString(`<img src="`)
400 parsedDescription.WriteString(uri) 404 parsedDescription.WriteString(esc(uri))
401 parsedDescription.WriteString(`" title="`) 405 parsedDescription.WriteString(`" title="`)
402 parsedDescription.WriteString(title) 406 parsedDescription.WriteString(esc(title))
403 parsedDescription.WriteString(`">`) 407 parsedDescription.WriteString(`">`)
404 } 408 }
405 } 409 }
406 } 410 }
407 case "br", "li", "ul", "p", "b": 411 case "br", "li", "ul", "p", "b":
408 parsedDescription.WriteString(token.String()) 412 // The bare tag, not token.String(): that would carry over
413 // whatever attributes DeviantArt's markup put on it.
414 if token.Type == html.EndTagToken {
415 parsedDescription.WriteString("</")
416 } else {
417 parsedDescription.WriteString("<")
418 }
419 parsedDescription.WriteString(token.Data)
420 parsedDescription.WriteString(">")
409 case "div": 421 case "div":
410 parsedDescription.WriteString("<p> ") 422 parsedDescription.WriteString("<p> ")
411 } 423 }
412 case html.TextToken: 424 case html.TextToken:
413 parsedDescription.Write(tt.Text()) 425 parsedDescription.WriteString(esc(string(tt.Text())))
414 } 426 }
415 } 427 }
416 } 428 }
app/util.go +40 −23
@@ -4,6 +4,8 @@ import (
4 "context" 4 "context"
5 "encoding/json" 5 "encoding/json"
6 "fmt" 6 "fmt"
7 htmlesc "html"
8 "html/template"
7 "io" 9 "io"
8 "net/http" 10 "net/http"
9 "net/url" 11 "net/url"
@@ -11,7 +13,6 @@ import (
11 "skunkyart/static" 13 "skunkyart/static"
12 "strconv" 14 "strconv"
13 "strings" 15 "strings"
14 "text/template"
15 "time" 16 "time"
16 17
17 "github.com/krazywarez/devianter" 18 "github.com/krazywarez/devianter"
@@ -41,6 +42,14 @@ func tryWithExitStatus(err error, code int) {
41 } 42 }
42} 43}
43 44
45// esc escapes s for use as HTML text or inside a quoted attribute. The Go-built
46// fragments bypass html/template's contextual escaping because they are handed
47// to it as template.HTML, so every DeviantArt-supplied string they contain has
48// to be escaped here instead.
49func esc(s string) string {
50 return htmlesc.EscapeString(s)
51}
52
44// restore swallows a panic in the calling goroutine so that one bad parse cannot 53// restore swallows a panic in the calling goroutine so that one bad parse cannot
45// take the whole process down. The panic is logged rather than dropped silently. 54// take the whole process down. The panic is logged rather than dropped silently.
46func restore() { 55func restore() {
@@ -101,44 +110,48 @@ type skunkyart struct {
101 API API 110 API API
102 Version string 111 Version string
103 112
113 // The template.HTML fields hold fragments the Go builders already
114 // escaped, so html/template inserts them as-is. Everything typed string is
115 // escaped by the template at the point of use.
104 Templates struct { 116 Templates struct {
105 About instanceAbout 117 About instanceAbout
106 118
107 SomeList string 119 SomeList template.HTML
108 DDStrips string 120 DDStrips template.HTML
109 Deviation struct { 121 Deviation struct {
110 Post devianter.Post 122 Post devianter.Post
111 Related string 123 Description template.HTML
112 StringTime string 124 Related template.HTML
113 Tags string 125 StringTime string
114 Comments string 126 Tags template.HTML
127 Comments template.HTML
115 } 128 }
116 129
117 GroupUser struct { 130 GroupUser struct {
118 GR devianter.GRuser 131 GR devianter.GRuser
119 Admins string 132 Admins template.HTML
120 Group bool 133 Group bool
121 CreationDate string 134 CreationDate string
122 135
123 About struct { 136 About struct {
124 A devianter.About 137 A devianter.About
125 138
126 DescriptionFormatted string 139 DescriptionFormatted template.HTML
127 Interests, Social string 140 Interests, Social template.HTML
128 Comments string 141 Comments template.HTML
129 BG string 142 BG string
130 BGMeta devianter.Deviation 143 BGMeta devianter.Deviation
131 } 144 }
132 145
133 Gallery struct { 146 Gallery struct {
134 Folders string 147 Folders template.HTML
135 Pages int 148 Pages int
136 List string 149 List template.HTML
137 } 150 }
138 } 151 }
139 Search struct { 152 Search struct {
140 Content devianter.Search 153 Content devianter.Search
141 List string 154 List template.HTML
142 } 155 }
143 } 156 }
144} 157}
@@ -182,15 +195,19 @@ func URLBuilder(host string, strs ...string) string {
182 return str.String() 195 return str.String()
183} 196}
184 197
185// Error responds 502 with the error DeviantArt reported upstream. 198// Error responds 502 with the error DeviantArt reported upstream. Only the
199// first line is shown: a WAF block arrives as a whole HTML page, which is
200// neither readable nor safe to echo.
186func (s skunkyart) Error(dAerr devianter.Error) { 201func (s skunkyart) Error(dAerr devianter.Error) {
187 s.Writer.WriteHeader(502) 202 s.Writer.WriteHeader(502)
188 203
204 reason, _, _ := strings.Cut(dAerr.Error, "\n")
205
189 var msg strings.Builder 206 var msg strings.Builder
190 msg.WriteString(`<html><link rel="stylesheet" href="`) 207 msg.WriteString(`<html><link rel="stylesheet" href="`)
191 msg.WriteString(URLBuilder(s.Host, "stylesheet")) 208 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
192 msg.WriteString(`" /><h3>DeviantArt error — '`) 209 msg.WriteString(`" /><h3>DeviantArt error — '`)
193 msg.WriteString(dAerr.Error) 210 msg.WriteString(esc(reason))
194 msg.WriteString("'</h3></html>") 211 msg.WriteString("'</h3></html>")
195 212
196 wr(s.Writer, msg.String()) 213 wr(s.Writer, msg.String())
@@ -319,11 +336,11 @@ func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) {
319func BuildUserPlate(host, name string) string { 336func BuildUserPlate(host, name string) string {
320 var htm strings.Builder 337 var htm strings.Builder
321 htm.WriteString(`<div class="user-plate"><img src="`) 338 htm.WriteString(`<div class="user-plate"><img src="`)
322 htm.WriteString(URLBuilder(host, "media", "emojitar", name, "?type=a")) 339 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a")))
323 htm.WriteString(`"><a href="`) 340 htm.WriteString(`"><a href="`)
324 htm.WriteString(URLBuilder(host, "group_user", "?type=about&q=", name)) 341 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
325 htm.WriteString(`">`) 342 htm.WriteString(`">`)
326 htm.WriteString(name) 343 htm.WriteString(esc(name))
327 htm.WriteString(`</a></div>`) 344 htm.WriteString(`</a></div>`)
328 return htm.String() 345 return htm.String()
329} 346}
@@ -355,7 +372,7 @@ func (s skunkyart) NavBase(c DeviationList) string {
355 prevrev := func(msg string, page int, onpage bool) { 372 prevrev := func(msg string, page int, onpage bool) {
356 if !onpage { 373 if !onpage {
357 list.WriteString(`<a href="`) 374 list.WriteString(`<a href="`)
358 list.WriteString(s._pth) 375 list.WriteString(esc(s._pth))
359 list.WriteString(`?p=`) 376 list.WriteString(`?p=`)
360 list.WriteString(strconv.Itoa(page)) 377 list.WriteString(strconv.Itoa(page))
361 if s.Type != 0 { 378 if s.Type != 0 {
@@ -364,11 +381,11 @@ func (s skunkyart) NavBase(c DeviationList) string {
364 } 381 }
365 if s.Query != "" { 382 if s.Query != "" {
366 list.WriteString("&q=") 383 list.WriteString("&q=")
367 list.WriteString(s.Query) 384 list.WriteString(esc(s.Query))
368 } 385 }
369 if f := s.Args.Get("folder"); f != "" { 386 if f := s.Args.Get("folder"); f != "" {
370 list.WriteString("&folder=") 387 list.WriteString("&folder=")
371 list.WriteString(f) 388 list.WriteString(esc(f))
372 } 389 }
373 list.WriteString(`">`) 390 list.WriteString(`">`)
374 list.WriteString(msg) 391 list.WriteString(msg)
app/wrapper.go +43 −40
@@ -1,6 +1,7 @@
1package app 1package app
2 2
3import ( 3import (
4 "html/template"
4 "regexp" 5 "regexp"
5 "strconv" 6 "strconv"
6 "strings" 7 "strings"
@@ -42,33 +43,33 @@ func (s skunkyart) GRUser() {
42 var about = &x.ModuleData.GroupAbout 43 var about = &x.ModuleData.GroupAbout
43 group.Group = true 44 group.Group = true
44 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String() 45 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String()
45 group.About.DescriptionFormatted = ParseDescription(s.Host, about.Description) 46 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
46 } else if false { 47 } else if false {
47 group.About.A = x.ModuleData.About 48 group.About.A = x.ModuleData.About
48 var about = &group.About.A 49 var about = &group.About.A
49 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() 50 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
50 group.About.DescriptionFormatted = ParseDescription(s.Host, about.Description) 51 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
51 52
52 for _, val := range x.ModuleData.About.SocialLinks { 53 for _, val := range x.ModuleData.About.SocialLinks {
53 var social strings.Builder 54 var social strings.Builder
54 social.WriteString(`<a target="_blank" href="`) 55 social.WriteString(`<a target="_blank" href="`)
55 social.WriteString(val.Value) 56 social.WriteString(esc(val.Value))
56 social.WriteString(`">`) 57 social.WriteString(`">`)
57 social.WriteString(val.Value) 58 social.WriteString(esc(val.Value))
58 social.WriteString("</a><br>") 59 social.WriteString("</a><br>")
59 group.About.Social += social.String() 60 group.About.Social += template.HTML(social.String()) //nolint:gosec // G203: escaped above
60 } 61 }
61 62
62 for _, val := range x.ModuleData.About.Interests { 63 for _, val := range x.ModuleData.About.Interests {
63 var interest strings.Builder 64 var interest strings.Builder
64 interest.WriteString(val.Label) 65 interest.WriteString(esc(val.Label))
65 interest.WriteString(": <b>") 66 interest.WriteString(": <b>")
66 interest.WriteString(val.Value) 67 interest.WriteString(esc(val.Value))
67 interest.WriteString("</b><br>") 68 interest.WriteString("</b><br>")
68 group.About.Interests += interest.String() 69 group.About.Interests += template.HTML(interest.String()) //nolint:gosec // G203: escaped above
69 } 70 }
70 } 71 }
71 group.About.Comments = s.ParseComments(devianter.GetComments(strconv.Itoa(group.GR.Gruser.ID), "", s.Page, 4)) 72 group.About.Comments = template.HTML(s.ParseComments(devianter.GetComments(strconv.Itoa(group.GR.Gruser.ID), "", s.Page, 4))) //nolint:gosec // G203: ParseComments escapes its input
72 73
73 case "cover_deviation": 74 case "cover_deviation":
74 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation 75 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
@@ -79,7 +80,7 @@ func (s skunkyart) GRUser() {
79 for _, z := range x.ModuleData.GroupAdmins.Results { 80 for _, z := range x.ModuleData.GroupAdmins.Results {
80 htm.WriteString(BuildUserPlate(s.Host, z.User.Username)) 81 htm.WriteString(BuildUserPlate(s.Host, z.User.Username))
81 } 82 }
82 group.Admins += htm.String() 83 group.Admins += template.HTML(htm.String()) //nolint:gosec // G203: BuildUserPlate escapes its input
83 } 84 }
84 85
85 } 86 }
@@ -110,9 +111,9 @@ func (s skunkyart) GRUser() {
110 } 111 }
111 112
112 if folderid > 0 || (s.Type == 'f' && all) { 113 if folderid > 0 || (s.Type == 'f' && all) {
113 group.Gallery.List = s.DeviationList(content.Content.Results, true, DeviationList{ 114 group.Gallery.List = template.HTML(s.DeviationList(content.Content.Results, true, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
114 More: content.Content.HasMore, 115 More: content.Content.HasMore,
115 }) 116 }))
116 } else { 117 } else {
117 for _, x := range content.Content.Gruser.Page.Modules { 118 for _, x := range content.Content.Gruser.Page.Modules {
118 if len(x.ModuleData.Folders.Results) != 0 { 119 if len(x.ModuleData.Folders.Results) != 0 {
@@ -124,11 +125,11 @@ func (s skunkyart) GRUser() {
124 125
125 if !x.Thumb.NSFW || CFG.Nsfw { 126 if !x.Thumb.NSFW || CFG.Nsfw {
126 folders.WriteString(`<a href="`) 127 folders.WriteString(`<a href="`)
127 folders.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, x.Thumb.Url)) 128 folders.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(s.Host, x.Thumb.Url)))
128 folders.WriteString(`"><img loading="lazy" src="`) 129 folders.WriteString(`"><img loading="lazy" src="`)
129 folders.WriteString(ParseMedia(s.Host, x.Thumb.Media)) 130 folders.WriteString(esc(ParseMedia(s.Host, x.Thumb.Media)))
130 folders.WriteString(`" title="`) 131 folders.WriteString(`" title="`)
131 folders.WriteString(x.Thumb.Title) 132 folders.WriteString(esc(x.Thumb.Title))
132 folders.WriteString(`"></a>`) 133 folders.WriteString(`"></a>`)
133 } else { 134 } else {
134 folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`) 135 folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`)
@@ -138,25 +139,25 @@ func (s skunkyart) GRUser() {
138 folders.WriteString(`<a href="group_user?folder=`) 139 folders.WriteString(`<a href="group_user?folder=`)
139 folders.WriteString(strconv.Itoa(x.FolderId)) 140 folders.WriteString(strconv.Itoa(x.FolderId))
140 folders.WriteString("&q=") 141 folders.WriteString("&q=")
141 folders.WriteString(s.Query) 142 folders.WriteString(esc(s.Query))
142 folders.WriteString("&type=") 143 folders.WriteString("&type=")
143 folders.WriteRune(s.Type) 144 folders.WriteRune(s.Type)
144 folders.WriteString(`">`) 145 folders.WriteString(`">`)
145 folders.WriteString(x.Name) 146 folders.WriteString(esc(x.Name))
146 folders.WriteString(`</a>`) 147 folders.WriteString(`</a>`)
147 148
148 folders.WriteString("</div>") 149 folders.WriteString("</div>")
149 } 150 }
150 } 151 }
151 folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> Content</h1>`) 152 folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> Content</h1>`)
152 group.Gallery.Folders = folders.String() 153 group.Gallery.Folders = template.HTML(folders.String()) //nolint:gosec // G203: escaped above
153 } 154 }
154 155
155 if x.Name == "folder_deviations" { 156 if x.Name == "folder_deviations" {
156 group.Gallery.List = s.DeviationList(x.ModuleData.Folder.Deviations, true, DeviationList{ 157 group.Gallery.List = template.HTML(s.DeviationList(x.ModuleData.Folder.Deviations, true, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
157 Pages: x.ModuleData.Folder.Pages, 158 Pages: x.ModuleData.Folder.Pages,
158 More: x.ModuleData.Folder.HasMore, 159 More: x.ModuleData.Folder.HasMore,
159 }) 160 }))
160 } 161 }
161 } 162 }
162 } 163 }
@@ -201,14 +202,14 @@ func (s skunkyart) Deviation(author, postname string) {
201 } 202 }
202 203
203 if post.Post.Deviation.TextContent.Excerpt != "" { 204 if post.Post.Deviation.TextContent.Excerpt != "" {
204 post.Post.Description = ParseDescription(s.Host, post.Post.Deviation.TextContent) 205 post.Description = template.HTML(ParseDescription(s.Host, post.Post.Deviation.TextContent)) //nolint:gosec // G203: ParseDescription escapes its input
205 } else { 206 } else {
206 post.Post.Description = ParseDescription(s.Host, post.Post.Deviation.Extended.DescriptionText) 207 post.Description = template.HTML(ParseDescription(s.Host, post.Post.Deviation.Extended.DescriptionText)) //nolint:gosec // G203: ParseDescription escapes its input
207 } 208 }
208 209
209 for _, x := range post.Post.Deviation.Extended.RelatedContent { 210 for _, x := range post.Post.Deviation.Extended.RelatedContent {
210 if len(x.Deviations) != 0 { 211 if len(x.Deviations) != 0 {
211 post.Related += s.DeviationList(x.Deviations, false) 212 post.Related += template.HTML(s.DeviationList(x.Deviations, false)) //nolint:gosec // G203: DeviationList escapes its input
212 } 213 }
213 } 214 }
214 215
@@ -216,15 +217,15 @@ func (s skunkyart) Deviation(author, postname string) {
216 for _, x := range post.Post.Deviation.Extended.Tags { 217 for _, x := range post.Post.Deviation.Extended.Tags {
217 var tag strings.Builder 218 var tag strings.Builder
218 tag.WriteString(` <a href="`) 219 tag.WriteString(` <a href="`)
219 tag.WriteString(URLBuilder(s.Host, "search", "?q=", x.Name, "&type=tag")) 220 tag.WriteString(esc(URLBuilder(s.Host, "search", "?q=", x.Name, "&type=tag")))
220 tag.WriteString(`">#`) 221 tag.WriteString(`">#`)
221 tag.WriteString(x.Name) 222 tag.WriteString(esc(x.Name))
222 tag.WriteString("</a>") 223 tag.WriteString("</a>")
223 224
224 post.Tags += tag.String() 225 post.Tags += template.HTML(tag.String()) //nolint:gosec // G203: escaped above
225 } 226 }
226 227
227 post.Comments = s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1)) 228 post.Comments = template.HTML(s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1))) //nolint:gosec // G203: ParseComments escapes its input
228 post.StringTime = post.Post.Deviation.PublishedTime.UTC().String() 229 post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
229 post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media) 230 post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media)
230 231
@@ -241,20 +242,20 @@ func (s skunkyart) DD() {
241 var strips strings.Builder 242 var strips strings.Builder
242 for _, x := range dd.Strips { 243 for _, x := range dd.Strips {
243 strips.WriteString(`<h3 class="`) 244 strips.WriteString(`<h3 class="`)
244 strips.WriteString(x.Codename) 245 strips.WriteString(esc(x.Codename))
245 strips.WriteString(`"> <a href="#`) 246 strips.WriteString(`"> <a href="#`)
246 strips.WriteString(x.Codename) 247 strips.WriteString(esc(x.Codename))
247 strips.WriteString(`"># </a>`) 248 strips.WriteString(`"># </a>`)
248 strips.WriteString(x.Title) 249 strips.WriteString(esc(x.Title))
249 strips.WriteString(`</h3>`) 250 strips.WriteString(`</h3>`)
250 251
251 strips.WriteString(s.DeviationList(x.Deviations, false)) 252 strips.WriteString(s.DeviationList(x.Deviations, false))
252 } 253 }
253 s.Templates.DDStrips = strips.String() 254 s.Templates.DDStrips = template.HTML(strips.String()) //nolint:gosec // G203: escaped above
254 s.Templates.SomeList = s.DeviationList(dd.Deviations, true, DeviationList{ 255 s.Templates.SomeList = template.HTML(s.DeviationList(dd.Deviations, true, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
255 Pages: 0, 256 Pages: 0,
256 More: dd.HasMore, 257 More: dd.HasMore,
257 }) 258 }))
258 if !s.Atom { 259 if !s.Atom {
259 s.ExecuteTemplate("daily.htm", "html", &s) 260 s.ExecuteTemplate("daily.htm", "html", &s)
260 } 261 }
@@ -310,14 +311,16 @@ func (s skunkyart) Search() {
310 } 311 }
311 312
312 if len(usernames) != 0 { 313 if len(usernames) != 0 {
313 ss.List += `<div class="content plates">` 314 var plates strings.Builder
315 plates.WriteString(`<div class="content plates">`)
314 for x := range len(usernames) { 316 for x := range len(usernames) {
315 ss.List += BuildUserPlate(s.Host, usernames[x]) 317 plates.WriteString(BuildUserPlate(s.Host, usernames[x]))
316 } 318 }
317 ss.List += `</div>` 319 plates.WriteString(`</div>`)
318 ss.List += s.NavBase(DeviationList{ 320 plates.WriteString(s.NavBase(DeviationList{
319 More: true, 321 More: true,
320 }) 322 }))
323 ss.List = template.HTML(plates.String()) //nolint:gosec // G203: BuildUserPlate escapes its input
321 } 324 }
322 default: 325 default:
323 s.ReturnHTTPError(400) 326 s.ReturnHTTPError(400)
@@ -331,10 +334,10 @@ func (s skunkyart) Search() {
331 return 334 return
332 } 335 }
333 336
334 ss.List = s.DeviationList(ss.Content.Results, false, DeviationList{ 337 ss.List = template.HTML(s.DeviationList(ss.Content.Results, false, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
335 Pages: ss.Content.Pages, 338 Pages: ss.Content.Pages,
336 More: ss.Content.HasMore, 339 More: ss.Content.HasMore,
337 }) 340 }))
338 } 341 }
339 342
340 s.ExecuteTemplate("search.htm", "html", &s) 343 s.ExecuteTemplate("search.htm", "html", &s)
static/html/deviantion.htm +2 −2
@@ -22,11 +22,11 @@
22 <span>{{T "deviation.published"}}<strong>{{.Templates.Deviation.StringTime}}</strong>; Views: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Views}}</strong>; Favourites: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Favourites}}</strong>; Downloads: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Downloads}}</strong> 22 <span>{{T "deviation.published"}}<strong>{{.Templates.Deviation.StringTime}}</strong>; Views: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Views}}</strong>; Favourites: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Favourites}}</strong>; Downloads: <strong>{{.Templates.Deviation.Post.Deviation.Stats.Downloads}}</strong>
23 <br><a target="_blank" href="https://www.deviantart.com/{{.Templates.Deviation.Post.Deviation.Author.Username}}/art/art-{{.Templates.Deviation.Post.Deviation.ID}}">{{T "deviation.original"}}</a> 23 <br><a target="_blank" href="https://www.deviantart.com/{{.Templates.Deviation.Post.Deviation.Author.Username}}/art/art-{{.Templates.Deviation.Post.Deviation.ID}}">{{T "deviation.original"}}</a>
24 </span> 24 </span>
25 {{if (ne .Templates.Deviation.Post.Description "")}} 25 {{if (ne .Templates.Deviation.Description "")}}
26 <figcaption> 26 <figcaption>
27 <details> 27 <details>
28 <summary>{{T "deviation.description"}}</summary> 28 <summary>{{T "deviation.description"}}</summary>
29 {{.Templates.Deviation.Post.Description}} 29 {{.Templates.Deviation.Description}}
30 </details> 30 </details>
31 </figcaption> 31 </figcaption>
32 {{end}} 32 {{end}}