svg.gno
11.02 Kb · 367 lines
1package safe
2
3import (
4 "errors"
5 "strconv"
6 "strings"
7 "unicode/utf8"
8)
9
10// AssertSVG checks an engine's raw SVG output with a strict allowlist
11// (SA-M3; it replaced a substring blacklist that namespace prefixes, URL
12// whitespace and CSS escapes could slip past). The document is tokenized
13// as XML and must be well formed:
14//
15// - only the elements in svgElements, without a namespace prefix: no
16// script, style, foreignObject, image, a, animation (set, animate*) or
17// anything else that can run code, load a resource or redefine the page;
18// - only the attributes in svgAttributes, without a prefix except
19// xlink:href and the two fixed xmlns declarations; event handlers and
20// style= are therefore impossible;
21// - attribute values hold no control character, backslash, '<', ':' or
22// non-allowlisted entity, so no URL scheme (javascript:, data:, http:)
23// can be spelled, however it is split or escaped; url() must be
24// url(#id) and href must be #id;
25// - no comments, CDATA, doctype, entities or processing instructions.
26//
27// The frontend shows art only through <img>, which never runs scripts; this
28// check is the defence in depth for every other place a data: URI ends up
29// (wallets, explorers, indexers; RA M-7, design §5.4).
30func AssertSVG(svg string, maxBytes int) error {
31 if len(svg) == 0 {
32 return errors.New("svg: empty")
33 }
34 if len(svg) > maxBytes {
35 return errors.New("svg: larger than " + strconv.Itoa(maxBytes) + " bytes")
36 }
37 if !utf8.ValidString(svg) {
38 return errors.New("svg: invalid UTF-8")
39 }
40 if !strings.HasPrefix(svg, "<svg") || !strings.HasSuffix(svg, "</svg>") {
41 return errors.New("svg: must start with <svg and end with </svg>")
42 }
43 p := &svgParser{s: svg}
44 return p.run()
45}
46
47const (
48 svgNS = "http://www.w3.org/2000/svg"
49 xlinkNS = "http://www.w3.org/1999/xlink"
50)
51
52// svgElements are the drawing elements an engine may use.
53var svgElements = map[string]bool{
54 "svg": true, "g": true, "defs": true, "symbol": true, "use": true,
55 "title": true, "desc": true,
56 "path": true, "rect": true, "circle": true, "ellipse": true,
57 "line": true, "polyline": true, "polygon": true,
58 "text": true, "tspan": true,
59 "linearGradient": true, "radialGradient": true, "stop": true,
60 "clipPath": true, "mask": true, "pattern": true,
61 "filter": true, "feGaussianBlur": true, "feOffset": true, "feBlend": true,
62 "feColorMatrix": true, "feComposite": true, "feFlood": true, "feMerge": true,
63 "feMergeNode": true, "feMorphology": true, "feTurbulence": true,
64 "feDisplacementMap": true, "feComponentTransfer": true, "feFuncR": true,
65 "feFuncG": true, "feFuncB": true, "feFuncA": true,
66}
67
68// svgAttributes are geometry, paint and filter attributes. Absent on
69// purpose: style, on*, src, every animation attribute (attributeName, to,
70// from, by, begin), and every prefixed name except xlink:href.
71var svgAttributes = map[string]bool{
72 "id": true, "class": true, "version": true, "viewBox": true, "preserveAspectRatio": true,
73 "x": true, "y": true, "x1": true, "y1": true, "x2": true, "y2": true,
74 "cx": true, "cy": true, "r": true, "rx": true, "ry": true, "fx": true, "fy": true, "fr": true,
75 "width": true, "height": true, "d": true, "points": true, "pathLength": true,
76 "transform": true, "href": true, "xlink:href": true,
77 "fill": true, "fill-opacity": true, "fill-rule": true,
78 "stroke": true, "stroke-width": true, "stroke-opacity": true, "stroke-linecap": true,
79 "stroke-linejoin": true, "stroke-dasharray": true, "stroke-dashoffset": true, "stroke-miterlimit": true,
80 "opacity": true, "color": true, "visibility": true, "display": true,
81 "clip-path": true, "clip-rule": true, "mask": true, "filter": true,
82 "shape-rendering": true, "vector-effect": true, "paint-order": true, "mix-blend-mode": true,
83 "font-family": true, "font-size": true, "font-weight": true, "font-style": true,
84 "text-anchor": true, "dominant-baseline": true, "letter-spacing": true,
85 "textLength": true, "lengthAdjust": true, "dx": true, "dy": true, "rotate": true,
86 "gradientUnits": true, "gradientTransform": true, "spreadMethod": true,
87 "offset": true, "stop-color": true, "stop-opacity": true,
88 "patternUnits": true, "patternContentUnits": true, "patternTransform": true,
89 "clipPathUnits": true, "maskUnits": true, "maskContentUnits": true,
90 "filterUnits": true, "primitiveUnits": true, "result": true, "in": true, "in2": true,
91 "stdDeviation": true, "mode": true, "type": true, "values": true, "operator": true,
92 "k1": true, "k2": true, "k3": true, "k4": true, "radius": true,
93 "flood-color": true, "flood-opacity": true, "baseFrequency": true, "numOctaves": true,
94 "seed": true, "stitchTiles": true, "scale": true, "xChannelSelector": true, "yChannelSelector": true,
95 "tableValues": true, "slope": true, "intercept": true, "amplitude": true, "exponent": true,
96 "xmlns": true, "xmlns:xlink": true,
97}
98
99type svgParser struct {
100 s string
101 i int
102 stack []string
103}
104
105func svgErr(msg string) error { return errors.New("svg: " + msg) }
106
107func (p *svgParser) run() error {
108 rootDone := false
109 for p.i < len(p.s) {
110 if rootDone {
111 return svgErr("content after the root element")
112 }
113 if p.s[p.i] != '<' {
114 if err := p.text(); err != nil {
115 return err
116 }
117 continue
118 }
119 if p.i+1 >= len(p.s) {
120 return svgErr("unterminated tag")
121 }
122 c := p.s[p.i+1]
123 switch {
124 case c == '!':
125 return svgErr("comments, CDATA, doctype and entity declarations are not allowed")
126 case c == '?':
127 return svgErr("processing instructions are not allowed")
128 case c == '/':
129 if err := p.closeTag(); err != nil {
130 return err
131 }
132 if len(p.stack) == 0 {
133 rootDone = true
134 }
135 default:
136 first := len(p.stack) == 0
137 name, selfClosed, err := p.openTag()
138 if err != nil {
139 return err
140 }
141 if first && name != "svg" {
142 return svgErr("the root element must be <svg>")
143 }
144 if selfClosed && len(p.stack) == 0 {
145 rootDone = true
146 }
147 }
148 }
149 if len(p.stack) != 0 {
150 return svgErr("unclosed element <" + p.stack[len(p.stack)-1] + ">")
151 }
152 if !rootDone {
153 return svgErr("no root element")
154 }
155 return nil
156}
157
158// text is character data: printable characters, whitespace and the five
159// XML entities only.
160func (p *svgParser) text() error {
161 if len(p.stack) == 0 {
162 return svgErr("text outside the root element")
163 }
164 start := p.i
165 for p.i < len(p.s) && p.s[p.i] != '<' {
166 p.i++
167 }
168 return checkChars(p.s[start:p.i], true)
169}
170
171func (p *svgParser) openTag() (string, bool, error) {
172 p.i++ // '<'
173 name := p.name()
174 if name == "" {
175 return "", false, svgErr("malformed tag")
176 }
177 if strings.Contains(name, ":") || !svgElements[name] {
178 return "", false, svgErr("element <" + name + "> is not allowed")
179 }
180 seen := map[string]bool{}
181 for {
182 sp := p.spaces()
183 if p.i >= len(p.s) {
184 return "", false, svgErr("unterminated tag <" + name + ">")
185 }
186 switch p.s[p.i] {
187 case '>':
188 p.i++
189 p.stack = append(p.stack, name)
190 return name, false, nil
191 case '/':
192 if p.i+1 >= len(p.s) || p.s[p.i+1] != '>' {
193 return "", false, svgErr("malformed tag <" + name + ">")
194 }
195 p.i += 2
196 return name, true, nil
197 }
198 if !sp {
199 return "", false, svgErr("attributes must be separated by whitespace in <" + name + ">")
200 }
201 attr := p.name()
202 if attr == "" {
203 return "", false, svgErr("malformed attribute in <" + name + ">")
204 }
205 if seen[attr] {
206 return "", false, svgErr("duplicate attribute " + attr)
207 }
208 seen[attr] = true
209 p.spaces()
210 if p.i >= len(p.s) || p.s[p.i] != '=' {
211 return "", false, svgErr("attribute " + attr + " needs a value")
212 }
213 p.i++
214 p.spaces()
215 if p.i >= len(p.s) || (p.s[p.i] != '"' && p.s[p.i] != '\'') {
216 return "", false, svgErr("attribute " + attr + " must be quoted")
217 }
218 q := p.s[p.i]
219 p.i++
220 start := p.i
221 for p.i < len(p.s) && p.s[p.i] != q {
222 p.i++
223 }
224 if p.i >= len(p.s) {
225 return "", false, svgErr("unterminated value of " + attr)
226 }
227 val := p.s[start:p.i]
228 p.i++
229 if err := checkAttr(name, attr, val); err != nil {
230 return "", false, err
231 }
232 }
233}
234
235func (p *svgParser) closeTag() error {
236 p.i += 2 // "</"
237 name := p.name()
238 p.spaces()
239 if p.i >= len(p.s) || p.s[p.i] != '>' {
240 return svgErr("malformed closing tag")
241 }
242 p.i++
243 if len(p.stack) == 0 || p.stack[len(p.stack)-1] != name {
244 return svgErr("mismatched closing tag </" + name + ">")
245 }
246 p.stack = p.stack[:len(p.stack)-1]
247 return nil
248}
249
250// name reads an XML name made of ASCII letters, digits, '-', '_', '.', ':'.
251func (p *svgParser) name() string {
252 start := p.i
253 for p.i < len(p.s) {
254 c := p.s[p.i]
255 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == ':' ||
256 (p.i > start && ((c >= '0' && c <= '9') || c == '-' || c == '.')) {
257 p.i++
258 continue
259 }
260 break
261 }
262 return p.s[start:p.i]
263}
264
265func (p *svgParser) spaces() bool {
266 start := p.i
267 for p.i < len(p.s) && isSpace(p.s[p.i]) {
268 p.i++
269 }
270 return p.i > start
271}
272
273func checkAttr(elem, attr, val string) error {
274 if !svgAttributes[attr] {
275 return svgErr("attribute " + attr + " is not allowed")
276 }
277 switch attr {
278 case "xmlns":
279 if val != svgNS {
280 return svgErr("xmlns must be " + svgNS)
281 }
282 return nil
283 case "xmlns:xlink":
284 if val != xlinkNS {
285 return svgErr("xmlns:xlink must be " + xlinkNS)
286 }
287 return nil
288 }
289 if err := checkChars(val, false); err != nil {
290 return svgErr("attribute " + attr + ": " + err.Error())
291 }
292 if strings.ContainsAny(val, ":\\<") {
293 return svgErr("attribute " + attr + " contains a forbidden character (':', '\\' or '<')")
294 }
295 if attr == "href" || attr == "xlink:href" {
296 if !isFragment(val) {
297 return svgErr("references must stay inside the document (#id)")
298 }
299 return nil
300 }
301 low := strings.ToLower(val)
302 for {
303 k := strings.Index(low, "url")
304 if k < 0 {
305 return nil
306 }
307 rest := strings.TrimLeft(low[k+3:], " ")
308 if !strings.HasPrefix(rest, "(") {
309 low = low[k+3:]
310 continue
311 }
312 rest = strings.TrimLeft(rest[1:], " ")
313 end := strings.IndexByte(rest, ')')
314 if end < 0 || !isFragment(strings.TrimRight(rest[:end], " ")) {
315 return svgErr("references must stay inside the document (url(#id))")
316 }
317 low = rest[end:]
318 }
319}
320
321// checkChars allows printable characters and the five XML entities; text
322// (character data) may also hold whitespace, attribute values only spaces.
323func checkChars(s string, isText bool) error {
324 for i := 0; i < len(s); i++ {
325 c := s[i]
326 switch {
327 case c == '&':
328 ok := false
329 for _, e := range []string{"&", "<", ">", """, "'"} {
330 if strings.HasPrefix(s[i:], e) {
331 ok = true
332 break
333 }
334 }
335 if !ok {
336 return svgErr("only & < > " ' entities are allowed")
337 }
338 case c == 0x7f || (c < 0x20 && !(isText && isSpace(c))):
339 return svgErr("control characters are not allowed")
340 case c == '>' && !isText:
341 return svgErr("'>' in an attribute value")
342 }
343 }
344 for _, r := range s {
345 if r >= 0x80 && r <= 0x9f {
346 return svgErr("control characters are not allowed")
347 }
348 }
349 return nil
350}
351
352func isFragment(v string) bool {
353 if len(v) < 2 || v[0] != '#' {
354 return false
355 }
356 for i := 1; i < len(v); i++ {
357 c := v[i]
358 if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') {
359 return false
360 }
361 }
362 return true
363}
364
365func isSpace(c byte) bool {
366 return c == ' ' || c == '\t' || c == '\n' || c == '\r'
367}