oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// svg.go sanitises an uploaded SVG before it is ever registered as a
2// deck asset (spec §2.7: "SVG is sanitised on registration").
3//
4// An SVG is a document, not a picture. It can carry <script>, event
5// handlers, foreign HTML, and references to anything on the web — and
6// this app serves an asset's bytes back from the deck's own origin
7// (assets.go), so a live <script> inside one would be script on the
8// origin that holds every session cookie this app has. That is the
9// whole reason this file exists, and it is why nothing here is
10// best-effort: what cannot be made safe is refused, not patched.
11//
12// Two passes, in this order, because the second depends on the first:
13//
14// 1. encoding/xml parses the whole document. It must be well-formed
15// XML, rooted at <svg>, with no DOCTYPE and no processing
16// instruction — a DOCTYPE is how entity expansion attacks are
17// spelled, and neither has any business in an uploaded picture.
18// A parse failure is a refusal.
19//
20// 2. A scanner rewrites the raw bytes: <script> and <foreignObject>
21// subtrees are dropped whole, and on* / javascript: / external
22// href attributes are dropped from every tag that survives.
23// Rewriting raw source rather than re-encoding the token stream is
24// deliberate — Go's xml.Encoder cannot round-trip an SVG's
25// namespace prefixes (xlink:href comes back out as the namespace
26// URL), and an "unchanged" file that no longer renders is worse
27// than one this refused. Pass 1 is what makes the scanner safe:
28// past it, every attribute value is quoted and every tag is
29// balanced, so the small parser below cannot be walked past.
30//
31// Anything the scanner could not resolve to a same-document reference
32// is then refused rather than stripped: an <image href> pointing at
33// another site is not a picture this deck owns, and quietly deleting
34// half of what somebody uploaded would be a picture missing a piece
35// with nothing said about it.
36package slides
37
38import (
39 "bytes"
40 "encoding/xml"
41 "errors"
42 "fmt"
43 "io"
44 "strings"
45)
46
47// errNotSVG is a file that is not a well-formed SVG document at all.
48var errNotSVG = errors.New("slides: not a well-formed SVG document")
49
50// errSVGExternal is an SVG that, after stripping, still points at
51// something outside itself.
52var errSVGExternal = errors.New("slides: this SVG references something outside itself")
53
54// droppedElements are the two element names whose whole subtree goes.
55//
56// script is the obvious one. foreignObject is the other half of the
57// same hole: it embeds arbitrary HTML inside the picture, which is a
58// second document with its own scripts, its own iframes and its own
59// event handlers, and sanitising HTML is not a job this file is going
60// to grow into.
61var droppedElements = map[string]bool{"script": true, "foreignobject": true}
62
63// urlAttributes are the attributes whose value is a reference. Each
64// one must resolve inside this document (a "#fragment") or be an inline
65// data: image; anything else is what errSVGExternal refuses.
66var urlAttributes = map[string]bool{
67 "href": true, "src": true, "xlink:href": true, "xlink:role": true,
68 "xlink:arcrole": true, "xlink:show": true, "xlink:actuate": true,
69}
70
71// sanitiseSVG returns svg with everything unsafe removed, or an error
72// naming why it cannot be made safe.
73//
74// The returned bytes are what gets stored and served. They may hash
75// differently from what was uploaded — that is the point, and
76// registerAsset re-Puts the result rather than trusting the ref the
77// browser asked to sign.
78func sanitiseSVG(raw []byte) ([]byte, error) {
79 if err := checkSVGWellFormed(raw); err != nil {
80 return nil, err
81 }
82 out, err := stripSVG(raw)
83 if err != nil {
84 return nil, err
85 }
86 // Belt and braces over the rewritten bytes: the scanner dropped
87 // what it recognised, and this refuses anything left that still
88 // reaches outside the document — a url() in a <style>, an @import,
89 // an entity reference the first pass would have caught but a
90 // rewrite could in principle have re-formed.
91 if err := checkSVGSelfContained(out); err != nil {
92 return nil, err
93 }
94 return out, nil
95}
96
97// checkSVGWellFormed is pass 1: real XML, rooted at <svg>, with no
98// DOCTYPE and no processing instruction.
99func checkSVGWellFormed(raw []byte) error {
100 dec := xml.NewDecoder(bytes.NewReader(raw))
101 // Strict is the default and stays on. What is turned OFF is entity
102 // resolution: with no Entity map an undeclared entity is an error,
103 // which is exactly the answer wanted — a document that needs one
104 // declared is a document with a DOCTYPE, refused below.
105 root := false
106 depth := 0
107 for {
108 tok, err := dec.Token()
109 if errors.Is(err, io.EOF) {
110 break
111 }
112 if err != nil {
113 return fmt.Errorf("%w: %v", errNotSVG, err)
114 }
115 switch t := tok.(type) {
116 case xml.ProcInst:
117 // The XML declaration itself is a ProcInst named "xml" and
118 // is fine; anything else is a processing instruction, which
119 // a picture has no use for.
120 if t.Target != "xml" {
121 return fmt.Errorf("%w: a processing instruction (<?%s?>)", errNotSVG, t.Target)
122 }
123 case xml.Directive:
124 // <!DOCTYPE …> and <!ENTITY …>. Comments arrive as
125 // xml.Comment, not here, so this is only ever a doctype or
126 // an entity declaration — the two shapes of an expansion
127 // attack.
128 return fmt.Errorf("%w: a document type or entity declaration", errNotSVG)
129 case xml.StartElement:
130 if depth == 0 {
131 if !strings.EqualFold(t.Name.Local, "svg") {
132 return fmt.Errorf("%w: the root element is <%s>, not <svg>", errNotSVG, t.Name.Local)
133 }
134 root = true
135 }
136 depth++
137 case xml.EndElement:
138 depth--
139 }
140 }
141 if !root {
142 return fmt.Errorf("%w: no <svg> element", errNotSVG)
143 }
144 return nil
145}
146
147// stripSVG is pass 2: the raw rewrite. It walks the bytes, copying
148// everything it does not have a reason to change.
149//
150// Safe to write by hand only because pass 1 already proved the document
151// is well-formed XML: every attribute value is quoted, every tag is
152// closed, and there is no bare '<' in text.
153func stripSVG(raw []byte) ([]byte, error) {
154 var out bytes.Buffer
155 // skip counts how deep inside a dropped subtree the scanner is. A
156 // depth rather than a flag, because <foreignObject> can contain
157 // another one.
158 skip := 0
159 i := 0
160 for i < len(raw) {
161 lt := bytes.IndexByte(raw[i:], '<')
162 if lt < 0 {
163 if skip == 0 {
164 out.Write(raw[i:])
165 }
166 break
167 }
168 if skip == 0 {
169 out.Write(raw[i : i+lt])
170 }
171 start := i + lt
172 end, err := tagEnd(raw, start)
173 if err != nil {
174 return nil, err
175 }
176 tag := raw[start:end]
177 name, closing, selfClosing := tagName(tag)
178 switch {
179 case name == "":
180 // A comment, a CDATA section or the XML declaration. Kept
181 // when it is not inside something being dropped; a comment
182 // carries no behaviour, and pass 1 already refused the
183 // directives that do.
184 if skip == 0 {
185 out.Write(tag)
186 }
187 case droppedElements[name]:
188 if closing {
189 if skip > 0 {
190 skip--
191 }
192 } else if !selfClosing {
193 skip++
194 }
195 // The tag itself is never written, open or closed.
196 case skip > 0:
197 // Inside a dropped subtree: nothing survives.
198 case closing:
199 out.Write(tag)
200 default:
201 cleaned, err := cleanTag(tag)
202 if err != nil {
203 return nil, err
204 }
205 out.Write(cleaned)
206 }
207 i = end
208 }
209 return out.Bytes(), nil
210}
211
212// tagEnd finds the byte just past the '>' that closes the markup
213// starting at raw[start] — comments and CDATA included, since a '>'
214// inside either is ordinary text.
215func tagEnd(raw []byte, start int) (int, error) {
216 rest := raw[start:]
217 for _, span := range []struct{ open, close string }{
218 {"<!--", "-->"},
219 {"<![CDATA[", "]]>"},
220 } {
221 if bytes.HasPrefix(rest, []byte(span.open)) {
222 n := bytes.Index(rest[len(span.open):], []byte(span.close))
223 if n < 0 {
224 return 0, fmt.Errorf("%w: an unclosed %s", errNotSVG, span.open)
225 }
226 return start + len(span.open) + n + len(span.close), nil
227 }
228 }
229 // An ordinary tag. Attribute values are quoted (pass 1 said so), so
230 // a '>' inside one is skipped by tracking the quote.
231 var quote byte
232 for i := 1; i < len(rest); i++ {
233 c := rest[i]
234 switch {
235 case quote != 0:
236 if c == quote {
237 quote = 0
238 }
239 case c == '"' || c == '\'':
240 quote = c
241 case c == '>':
242 return start + i + 1, nil
243 }
244 }
245 return 0, fmt.Errorf("%w: an unclosed tag", errNotSVG)
246}
247
248// tagName reads the lowercased element name out of a tag, and whether
249// it is a closing tag and whether it closes itself. An empty name means
250// "not an element" — a comment, CDATA, or the XML declaration.
251func tagName(tag []byte) (name string, closing, selfClosing bool) {
252 if len(tag) < 2 || tag[0] != '<' {
253 return "", false, false
254 }
255 i := 1
256 if tag[i] == '/' {
257 closing = true
258 i++
259 } else if tag[i] == '!' || tag[i] == '?' {
260 return "", false, false
261 }
262 j := i
263 for j < len(tag) && !isSpaceByte(tag[j]) && tag[j] != '>' && tag[j] != '/' {
264 j++
265 }
266 name = strings.ToLower(string(tag[i:j]))
267 // A namespace prefix is not part of the name for the purpose of
268 // deciding what to drop: <svg:script> is a script.
269 if k := strings.LastIndexByte(name, ':'); k >= 0 {
270 name = name[k+1:]
271 }
272 selfClosing = bytes.HasSuffix(bytes.TrimRight(tag[:len(tag)-1], " \t\r\n"), []byte("/"))
273 return name, closing, selfClosing
274}
275
276// cleanTag re-emits one start tag with the unsafe attributes gone. An
277// attribute whose reference points outside the document is a refusal
278// rather than a removal, so what comes back is either the whole picture
279// or none of it.
280func cleanTag(tag []byte) ([]byte, error) {
281 name, _, _ := tagName(tag)
282 // Everything after the element name and before the closing '>' (and
283 // before a self-closing '/').
284 open := 1 + len(name)
285 for open < len(tag) && !isSpaceByte(tag[open]) && tag[open] != '>' && tag[open] != '/' {
286 open++
287 }
288 head := tag[:open]
289 body := tag[open : len(tag)-1]
290 tail := ">"
291 if trimmed := bytes.TrimRight(body, " \t\r\n"); bytes.HasSuffix(trimmed, []byte("/")) {
292 body = trimmed[:len(trimmed)-1]
293 tail = "/>"
294 }
295
296 var kept []string
297 for _, at := range attributes(body) {
298 low := strings.ToLower(at.name)
299 if strings.HasPrefix(low, "on") {
300 continue // an event handler, whatever it is called
301 }
302 value := unescapeXML(at.value)
303 if hasScriptScheme(value) {
304 continue
305 }
306 if urlAttributes[low] || urlAttributes[localName(low)] {
307 ok, external := referenceIsLocal(value)
308 if external {
309 return nil, fmt.Errorf("%w: <%s %s=%q>", errSVGExternal, name, at.name, at.value)
310 }
311 if !ok {
312 continue
313 }
314 }
315 if low == "style" && hasExternalCSS(value) {
316 return nil, fmt.Errorf("%w: <%s style=%q>", errSVGExternal, name, at.value)
317 }
318 kept = append(kept, at.raw)
319 }
320
321 var out bytes.Buffer
322 out.Write(head)
323 for _, a := range kept {
324 out.WriteByte(' ')
325 out.WriteString(a)
326 }
327 out.WriteString(tail)
328 return out.Bytes(), nil
329}
330
331// attr is one attribute as it was written, plus its parts.
332type attr struct{ name, value, raw string }
333
334// attributes splits a start tag's body into the attributes it declares.
335// Pass 1 guarantees every value is quoted, so this needs no recovery
336// path for a bare one.
337func attributes(body []byte) []attr {
338 var out []attr
339 i := 0
340 for i < len(body) {
341 for i < len(body) && isSpaceByte(body[i]) {
342 i++
343 }
344 if i >= len(body) {
345 break
346 }
347 nameStart := i
348 for i < len(body) && body[i] != '=' && !isSpaceByte(body[i]) {
349 i++
350 }
351 name := string(body[nameStart:i])
352 for i < len(body) && isSpaceByte(body[i]) {
353 i++
354 }
355 if i >= len(body) || body[i] != '=' {
356 // A valueless attribute. Not legal XML, so pass 1 has
357 // already refused it; kept verbatim rather than dropped, so
358 // this function has no branch that depends on being
359 // unreachable.
360 out = append(out, attr{name: name, raw: name})
361 continue
362 }
363 i++ // '='
364 for i < len(body) && isSpaceByte(body[i]) {
365 i++
366 }
367 if i >= len(body) {
368 break
369 }
370 quote := body[i]
371 i++
372 valueStart := i
373 for i < len(body) && body[i] != quote {
374 i++
375 }
376 value := string(body[valueStart:i])
377 if i < len(body) {
378 i++ // the closing quote
379 }
380 out = append(out, attr{name: name, value: value, raw: string(body[nameStart:i])})
381 }
382 return out
383}
384
385// localName drops a namespace prefix: "xlink:href" is "href".
386func localName(name string) string {
387 if k := strings.LastIndexByte(name, ':'); k >= 0 {
388 return name[k+1:]
389 }
390 return name
391}
392
393// hasScriptScheme reports whether a value is a javascript: (or vbscript:)
394// URL, whitespace and control characters between the letters and all —
395// the classic way past a naive prefix test.
396func hasScriptScheme(v string) bool {
397 var b strings.Builder
398 for _, r := range v {
399 if r <= ' ' || r == 0x7f {
400 continue
401 }
402 b.WriteRune(r)
403 if b.Len() > 32 {
404 break
405 }
406 }
407 s := strings.ToLower(b.String())
408 return strings.HasPrefix(s, "javascript:") || strings.HasPrefix(s, "vbscript:") || strings.HasPrefix(s, "data:text/html")
409}
410
411// referenceIsLocal judges one reference. ok says keep it; external says
412// refuse the whole document.
413//
414// Kept: a same-document fragment ("#gradient1"), and an inline image
415// (a data: URL naming an image type — no bytes leave the page for one,
416// and the browser will not execute it).
417//
418// Refused: anything with a scheme or a host. Dropped quietly: an empty
419// value and a relative path, neither of which resolves to anything
420// once the file is served from the object store.
421func referenceIsLocal(v string) (ok, external bool) {
422 s := strings.TrimSpace(v)
423 if s == "" {
424 return false, false
425 }
426 if strings.HasPrefix(s, "#") {
427 return true, false
428 }
429 low := strings.ToLower(s)
430 if strings.HasPrefix(low, "data:image/") && !strings.Contains(low, "svg") {
431 // data:image/svg+xml is another document, with everything this
432 // file exists to strip inside it and no second pass to strip
433 // it there.
434 return true, false
435 }
436 if strings.HasPrefix(low, "//") || strings.Contains(schemePrefix(low), ":") {
437 return false, true
438 }
439 return false, false
440}
441
442// schemePrefix is everything up to the first '/', '?' or '#' — where a
443// scheme would be if there is one.
444func schemePrefix(s string) string {
445 if i := strings.IndexAny(s, "/?#"); i >= 0 {
446 return s[:i]
447 }
448 return s
449}
450
451// hasExternalCSS reports whether a chunk of CSS reaches outside the
452// document: an @import, or a url() that is not a fragment or an inline
453// image.
454func hasExternalCSS(css string) bool {
455 low := strings.ToLower(css)
456 if strings.Contains(low, "@import") {
457 return true
458 }
459 for rest := low; ; {
460 i := strings.Index(rest, "url(")
461 if i < 0 {
462 return false
463 }
464 rest = rest[i+len("url("):]
465 j := strings.IndexByte(rest, ')')
466 if j < 0 {
467 return true // an unclosed url(, which is not something to guess about
468 }
469 arg := strings.Trim(strings.TrimSpace(rest[:j]), `"'`)
470 if ok, _ := referenceIsLocal(arg); !ok {
471 return true
472 }
473 rest = rest[j:]
474 }
475}
476
477// checkSVGSelfContained is the last look over the rewritten bytes:
478// every <style> element's CSS, and any entity reference that survived.
479func checkSVGSelfContained(out []byte) error {
480 dec := xml.NewDecoder(bytes.NewReader(out))
481 inStyle := 0
482 for {
483 tok, err := dec.Token()
484 if errors.Is(err, io.EOF) {
485 return nil
486 }
487 if err != nil {
488 // The rewrite broke the document. Never expected, and a
489 // refusal rather than a shrug: serving bytes this file
490 // cannot re-read is exactly the state it exists to prevent.
491 return fmt.Errorf("%w: %v", errNotSVG, err)
492 }
493 switch t := tok.(type) {
494 case xml.StartElement:
495 if strings.EqualFold(t.Name.Local, "style") {
496 inStyle++
497 }
498 case xml.EndElement:
499 if strings.EqualFold(t.Name.Local, "style") && inStyle > 0 {
500 inStyle--
501 }
502 case xml.CharData:
503 if inStyle > 0 && hasExternalCSS(string(t)) {
504 return fmt.Errorf("%w: a stylesheet reaches outside it", errSVGExternal)
505 }
506 }
507 }
508}
509
510// unescapeXML resolves the five predefined entities and nothing else —
511// enough to judge a scheme written as "java&#115;cript:". Pass 1
512// already refused any document that declares its own entities.
513func unescapeXML(v string) string {
514 var wrapper struct {
515 Value string `xml:",chardata"`
516 }
517 if err := xml.NewDecoder(strings.NewReader("<v>" + v + "</v>")).Decode(&wrapper); err != nil {
518 return v
519 }
520 return wrapper.Value
521}
522
523func isSpaceByte(c byte) bool {
524 return c == ' ' || c == '\t' || c == '\r' || c == '\n'
525}
526