File view with blame information shown in the left gutter beside each line.

barerepo / server / internal/markup/sanitize.go
218 lines · 6.5kb · master
log files threads runs releases config jump to file t
133728e barerepo 1mo
1
// Package markup renders markdown then sanitizes it, and never trusts the renderer. Chapter 42.
133728e barerepo 1mo
2
package markup
133728e barerepo 1mo
3
133728e barerepo 1mo
4
import (
133728e barerepo 1mo
5
"bytes"
133728e barerepo 1mo
6
"html"
133728e barerepo 1mo
7
"io"
133728e barerepo 1mo
8
"strconv"
133728e barerepo 1mo
9
"strings"
133728e barerepo 1mo
10
133728e barerepo 1mo
11
"github.com/yuin/goldmark"
133728e barerepo 1mo
12
"github.com/yuin/goldmark/extension"
133728e barerepo 1mo
13
xhtml "golang.org/x/net/html"
133728e barerepo 1mo
14
"golang.org/x/net/html/atom"
133728e barerepo 1mo
15
)
133728e barerepo 1mo
16
133728e barerepo 1mo
17
// allowed is chapter 42.1's list, and an allowlist, so it strips elements not yet invented.
133728e barerepo 1mo
18
var allowed = map[atom.Atom]bool{
133728e barerepo 1mo
19
atom.P: true, atom.Br: true, atom.Strong: true, atom.Em: true, atom.Del: true,
133728e barerepo 1mo
20
atom.Code: true, atom.Pre: true, atom.Blockquote: true,
133728e barerepo 1mo
21
atom.H1: true, atom.H2: true, atom.H3: true, atom.H4: true, atom.H5: true, atom.H6: true,
133728e barerepo 1mo
22
atom.Ul: true, atom.Ol: true, atom.Li: true,
133728e barerepo 1mo
23
atom.A: true, atom.Img: true,
133728e barerepo 1mo
24
atom.Table: true, atom.Thead: true, atom.Tbody: true, atom.Tr: true,
133728e barerepo 1mo
25
atom.Th: true, atom.Td: true,
133728e barerepo 1mo
26
atom.Hr: true,
133728e barerepo 1mo
27
}
133728e barerepo 1mo
28
133728e barerepo 1mo
29
// allowedAttrs is the complete attribute list from chapter 42.1.
133728e barerepo 1mo
30
var allowedAttrs = map[atom.Atom]map[string]bool{
133728e barerepo 1mo
31
atom.A: {"href": true, "title": true},
133728e barerepo 1mo
32
atom.Img: {"src": true, "alt": true, "title": true},
133728e barerepo 1mo
33
atom.Code: {"class": true}, // language-* only, checked below
133728e barerepo 1mo
34
atom.Ol: {"start": true}, // digits only, checked below
133728e barerepo 1mo
35
}
133728e barerepo 1mo
36
133728e barerepo 1mo
37
// dropWhole takes the contents too, or a stripped <script> leaves its source on the page.
133728e barerepo 1mo
38
var dropWhole = map[atom.Atom]bool{
133728e barerepo 1mo
39
atom.Script: true, atom.Style: true, atom.Iframe: true, atom.Object: true,
133728e barerepo 1mo
40
atom.Embed: true, atom.Form: true, atom.Svg: true, atom.Math: true,
133728e barerepo 1mo
41
atom.Template: true, atom.Noscript: true,
133728e barerepo 1mo
42
}
133728e barerepo 1mo
43
133728e barerepo 1mo
44
// void elements have no closing tag.
133728e barerepo 1mo
45
var void = map[atom.Atom]bool{atom.Br: true, atom.Img: true, atom.Hr: true}
133728e barerepo 1mo
46
133728e barerepo 1mo
47
// Render turns a comment body into HTML that is safe to put on a page.
133728e barerepo 1mo
48
func Render(markdown string) string {
133728e barerepo 1mo
49
var buf bytes.Buffer
133728e barerepo 1mo
50
// GFM adds tables and strikethrough, both allowed, and raw html stays off by default.
133728e barerepo 1mo
51
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
133728e barerepo 1mo
52
if err := md.Convert([]byte(markdown), &buf); err != nil {
133728e barerepo 1mo
53
// A body that will not render is shown as the text it is, escaped.
133728e barerepo 1mo
54
return "<p>" + html.EscapeString(markdown) + "</p>"
133728e barerepo 1mo
55
}
133728e barerepo 1mo
56
return Sanitize(buf.String())
133728e barerepo 1mo
57
}
133728e barerepo 1mo
58
133728e barerepo 1mo
59
// Sanitize strips everything chapter 42.1 does not allow.
133728e barerepo 1mo
60
func Sanitize(unsafe string) string {
133728e barerepo 1mo
61
var out strings.Builder
133728e barerepo 1mo
62
z := xhtml.NewTokenizer(strings.NewReader(unsafe))
133728e barerepo 1mo
63
// skipped holds the tags being dropped whole, so the matching end tag is the one that resumes.
133728e barerepo 1mo
64
var skipped []atom.Atom
133728e barerepo 1mo
65
var open []atom.Atom
133728e barerepo 1mo
66
133728e barerepo 1mo
67
for {
133728e barerepo 1mo
68
switch z.Next() {
133728e barerepo 1mo
69
case xhtml.ErrorToken:
133728e barerepo 1mo
70
if z.Err() != io.EOF {
133728e barerepo 1mo
71
return "" // a body that will not parse is not shown at all
133728e barerepo 1mo
72
}
133728e barerepo 1mo
73
// Close anything the input left open.
133728e barerepo 1mo
74
for i := len(open) - 1; i >= 0; i-- {
133728e barerepo 1mo
75
out.WriteString("</" + open[i].String() + ">")
133728e barerepo 1mo
76
}
133728e barerepo 1mo
77
return out.String()
133728e barerepo 1mo
78
133728e barerepo 1mo
79
case xhtml.TextToken:
133728e barerepo 1mo
80
if len(skipped) == 0 {
133728e barerepo 1mo
81
out.WriteString(html.EscapeString(string(z.Text())))
133728e barerepo 1mo
82
}
133728e barerepo 1mo
83
133728e barerepo 1mo
84
case xhtml.StartTagToken:
133728e barerepo 1mo
85
name, hasAttr := z.TagName()
133728e barerepo 1mo
86
a := atom.Lookup(name)
133728e barerepo 1mo
87
if len(skipped) > 0 || dropWhole[a] {
133728e barerepo 1mo
88
if !void[a] {
133728e barerepo 1mo
89
skipped = append(skipped, a)
133728e barerepo 1mo
90
}
133728e barerepo 1mo
91
continue
133728e barerepo 1mo
92
}
133728e barerepo 1mo
93
if !allowed[a] {
133728e barerepo 1mo
94
continue // strip the tag, keep what is inside it
133728e barerepo 1mo
95
}
133728e barerepo 1mo
96
if writeTag(&out, z, a, hasAttr) && !void[a] {
133728e barerepo 1mo
97
open = append(open, a)
133728e barerepo 1mo
98
}
133728e barerepo 1mo
99
133728e barerepo 1mo
100
case xhtml.SelfClosingTagToken:
133728e barerepo 1mo
101
name, hasAttr := z.TagName()
133728e barerepo 1mo
102
a := atom.Lookup(name)
133728e barerepo 1mo
103
if len(skipped) > 0 || dropWhole[a] || !allowed[a] {
133728e barerepo 1mo
104
continue
133728e barerepo 1mo
105
}
133728e barerepo 1mo
106
writeTag(&out, z, a, hasAttr)
133728e barerepo 1mo
107
133728e barerepo 1mo
108
case xhtml.EndTagToken:
133728e barerepo 1mo
109
name, _ := z.TagName()
133728e barerepo 1mo
110
a := atom.Lookup(name)
133728e barerepo 1mo
111
if len(skipped) > 0 {
133728e barerepo 1mo
112
// Unwind to the tag this closes, so what it left open is dropped with it.
133728e barerepo 1mo
113
for i := len(skipped) - 1; i >= 0; i-- {
133728e barerepo 1mo
114
if skipped[i] == a {
133728e barerepo 1mo
115
skipped = skipped[:i]
133728e barerepo 1mo
116
break
133728e barerepo 1mo
117
}
133728e barerepo 1mo
118
}
133728e barerepo 1mo
119
continue
133728e barerepo 1mo
120
}
133728e barerepo 1mo
121
if !allowed[a] || void[a] {
133728e barerepo 1mo
122
continue
133728e barerepo 1mo
123
}
133728e barerepo 1mo
124
// Only close a tag this sanitizer actually opened.
133728e barerepo 1mo
125
for i := len(open) - 1; i >= 0; i-- {
133728e barerepo 1mo
126
if open[i] == a {
133728e barerepo 1mo
127
out.WriteString("</" + a.String() + ">")
133728e barerepo 1mo
128
open = append(open[:i], open[i+1:]...)
133728e barerepo 1mo
129
break
133728e barerepo 1mo
130
}
133728e barerepo 1mo
131
}
133728e barerepo 1mo
132
}
133728e barerepo 1mo
133
}
133728e barerepo 1mo
134
}
133728e barerepo 1mo
135
133728e barerepo 1mo
136
// blockedImage is chapter 42.2's "say so in the UI", because a silent gap looks like a broken page.
133728e barerepo 1mo
137
const blockedImage = `<span class="blocked">remote image blocked, it would tell its host who read this</span>`
133728e barerepo 1mo
138
133728e barerepo 1mo
139
// writeTag emits one start tag, and reports whether it wrote one, since a blocked image writes none.
133728e barerepo 1mo
140
func writeTag(out *strings.Builder, z *xhtml.Tokenizer, a atom.Atom, hasAttr bool) bool {
133728e barerepo 1mo
141
permitted := allowedAttrs[a]
133728e barerepo 1mo
142
external, blocked := false, false
133728e barerepo 1mo
143
var attrs strings.Builder
133728e barerepo 1mo
144
for hasAttr {
133728e barerepo 1mo
145
var k, v []byte
133728e barerepo 1mo
146
k, v, hasAttr = z.TagAttr()
133728e barerepo 1mo
147
key := strings.ToLower(string(k))
133728e barerepo 1mo
148
value := string(v)
133728e barerepo 1mo
149
133728e barerepo 1mo
150
// on* and style are on no allowlist, and this strips them from everything, explicitly.
133728e barerepo 1mo
151
if strings.HasPrefix(key, "on") || key == "style" || !permitted[key] {
133728e barerepo 1mo
152
continue
133728e barerepo 1mo
153
}
133728e barerepo 1mo
154
switch {
133728e barerepo 1mo
155
case key == "href" || key == "src":
133728e barerepo 1mo
156
clean, ok := safeURL(value)
133728e barerepo 1mo
157
if !ok {
133728e barerepo 1mo
158
continue
133728e barerepo 1mo
159
}
133728e barerepo 1mo
160
if a == atom.Img && isRemote(clean) {
133728e barerepo 1mo
161
// A remote image leaks the reader's address to whoever wrote the comment. 42.2.
133728e barerepo 1mo
162
blocked = true
133728e barerepo 1mo
163
continue
133728e barerepo 1mo
164
}
133728e barerepo 1mo
165
if isRemote(clean) {
133728e barerepo 1mo
166
external = true
133728e barerepo 1mo
167
}
133728e barerepo 1mo
168
value = clean
133728e barerepo 1mo
169
case key == "start" && a == atom.Ol:
133728e barerepo 1mo
170
// A list that starts at six says six, and digits are the whole of what start may hold.
133728e barerepo 1mo
171
if _, err := strconv.Atoi(value); err != nil {
133728e barerepo 1mo
172
continue
133728e barerepo 1mo
173
}
133728e barerepo 1mo
174
case key == "class" && a == atom.Code:
133728e barerepo 1mo
175
if !strings.HasPrefix(value, "language-") {
133728e barerepo 1mo
176
continue
133728e barerepo 1mo
177
}
133728e barerepo 1mo
178
}
133728e barerepo 1mo
179
attrs.WriteString(" " + key + `="` + html.EscapeString(value) + `"`)
133728e barerepo 1mo
180
}
133728e barerepo 1mo
181
if blocked {
133728e barerepo 1mo
182
out.WriteString(blockedImage)
133728e barerepo 1mo
183
return false
133728e barerepo 1mo
184
}
133728e barerepo 1mo
185
out.WriteString("<" + a.String() + attrs.String())
133728e barerepo 1mo
186
if a == atom.A && external {
133728e barerepo 1mo
187
out.WriteString(` rel="nofollow noopener noreferrer"`)
133728e barerepo 1mo
188
}
133728e barerepo 1mo
189
out.WriteString(">")
133728e barerepo 1mo
190
return true
133728e barerepo 1mo
191
}
133728e barerepo 1mo
192
133728e barerepo 1mo
193
// safeURL allows http, https and mailto, checked after decoding, since a browser decodes too.
133728e barerepo 1mo
194
func safeURL(raw string) (string, bool) {
133728e barerepo 1mo
195
decoded := html.UnescapeString(raw)
133728e barerepo 1mo
196
trimmed := strings.TrimLeft(decoded, " \t\r\n\x00\v\f")
133728e barerepo 1mo
197
lower := strings.ToLower(trimmed)
133728e barerepo 1mo
198
133728e barerepo 1mo
199
// A scheme is what precedes the first colon, if it comes before any slash, question or hash.
133728e barerepo 1mo
200
if i := strings.IndexAny(lower, ":/?#"); i >= 0 && lower[i] == ':' {
133728e barerepo 1mo
201
scheme := lower[:i]
133728e barerepo 1mo
202
switch scheme {
133728e barerepo 1mo
203
case "http", "https", "mailto":
133728e barerepo 1mo
204
default:
133728e barerepo 1mo
205
return "", false
133728e barerepo 1mo
206
}
133728e barerepo 1mo
207
}
133728e barerepo 1mo
208
if strings.ContainsAny(trimmed, "\x00\n\r") {
133728e barerepo 1mo
209
return "", false
133728e barerepo 1mo
210
}
133728e barerepo 1mo
211
return trimmed, true
133728e barerepo 1mo
212
}
133728e barerepo 1mo
213
133728e barerepo 1mo
214
func isRemote(u string) bool {
133728e barerepo 1mo
215
l := strings.ToLower(u)
133728e barerepo 1mo
216
return strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") ||
133728e barerepo 1mo
217
strings.HasPrefix(l, "//")
133728e barerepo 1mo
218
}
history · rawbarerepo 0.1.0