| 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. |
| 36 | package slides |
| 37 | |
| 38 | import ( |
| 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. |
| 48 | var 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. |
| 52 | var 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. |
| 61 | var 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. |
| 66 | var 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. |
| 78 | func 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. |
| 99 | func 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. |
| 153 | func 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. |
| 215 | func 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. |
| 251 | func 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. |
| 280 | func 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. |
| 332 | type 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. |
| 337 | func 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". |
| 386 | func 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. |
| 396 | func 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. |
| 421 | func 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. |
| 444 | func 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. |
| 454 | func 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. |
| 479 | func 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 "javascript:". Pass 1 |
| 512 | // already refused any document that declares its own entities. |
| 513 | func 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 | |
| 523 | func isSpaceByte(c byte) bool { |
| 524 | return c == ' ' || c == '\t' || c == '\r' || c == '\n' |
| 525 | } |
| 526 | |