| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/xml" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // SVG sanitizing shared by Markdown images and chat blocks. A strict XML pass |
| 12 | // drops executable or external content before bytes reach an image source. |
| 13 | |
| 14 | // MarkdownSVGView is the renderer-safe result of sanitizing a chat code block. |
| 15 | // SVG is the sanitized markup; the caller turns it into an image source. |
| 16 | type MarkdownSVGView struct { |
| 17 | OK bool `json:"ok"` |
| 18 | SVG string `json:"svg,omitempty"` |
| 19 | Reason string `json:"reason,omitempty"` |
| 20 | } |
| 21 | |
| 22 | // The automatic preview ceiling keeps one pasted diagram from stalling the |
| 23 | // transcript. Past it the code block stays source with a short explanation. |
| 24 | const ( |
| 25 | markdownSVGPreviewMaxBytes = 1 << 20 |
| 26 | markdownSVGPreviewMaxElements = 10000 |
| 27 | markdownSVGPreviewMaxDepth = 128 |
| 28 | ) |
| 29 | |
| 30 | // The SVG document namespace, and the attribute namespace `xmlns` itself is |
| 31 | // reported under by encoding/xml. |
| 32 | const markdownSVGNamespace = "http://www.w3.org/2000/svg" |
| 33 | |
| 34 | func isNamespaceDeclaration(name xml.Name) bool { |
| 35 | return name.Space == "xmlns" || (name.Space == "" && name.Local == "xmlns") |
| 36 | } |
| 37 | |
| 38 | type svgSanitizeLimits struct { |
| 39 | maxBytes int |
| 40 | maxElements int |
| 41 | maxDepth int |
| 42 | } |
| 43 | |
| 44 | // SanitizeMarkdownSVG converts a model-authored SVG document into renderer-safe |
| 45 | // markup. It performs no file read and no network access; the content is the |
| 46 | // only input. |
| 47 | func (a *App) SanitizeMarkdownSVG(content string) MarkdownSVGView { |
| 48 | sanitized, ok := sanitizeMarkdownSVG([]byte(content), svgSanitizeLimits{ |
| 49 | maxBytes: markdownSVGPreviewMaxBytes, |
| 50 | maxElements: markdownSVGPreviewMaxElements, |
| 51 | maxDepth: markdownSVGPreviewMaxDepth, |
| 52 | }) |
| 53 | if !ok { |
| 54 | return MarkdownSVGView{Reason: markdownSVGFailureReason(len(content))} |
| 55 | } |
| 56 | return MarkdownSVGView{OK: true, SVG: string(sanitized)} |
| 57 | } |
| 58 | |
| 59 | // markdownSVGFailureReason distinguishes the one refusal the reader can act on |
| 60 | // (a document too large to preview) from malformed markup. |
| 61 | func markdownSVGFailureReason(size int) string { |
| 62 | if size > markdownSVGPreviewMaxBytes { |
| 63 | return "too-large" |
| 64 | } |
| 65 | return "invalid" |
| 66 | } |
| 67 | |
| 68 | var markdownSVGForbiddenElements = map[string]bool{ |
| 69 | "animate": true, |
| 70 | "animatemotion": true, |
| 71 | "animatetransform": true, |
| 72 | "audio": true, |
| 73 | "embed": true, |
| 74 | "foreignobject": true, |
| 75 | "iframe": true, |
| 76 | "object": true, |
| 77 | "script": true, |
| 78 | "set": true, |
| 79 | "style": true, |
| 80 | "video": true, |
| 81 | } |
| 82 | |
| 83 | func sanitizeMarkdownSVG(body []byte, limits svgSanitizeLimits) ([]byte, bool) { |
| 84 | if limits.maxBytes > 0 && len(body) > limits.maxBytes { |
| 85 | return nil, false |
| 86 | } |
| 87 | trimmed := bytes.TrimSpace(body) |
| 88 | trimmed = bytes.TrimPrefix(trimmed, []byte{0xef, 0xbb, 0xbf}) |
| 89 | trimmed = bytes.TrimSpace(trimmed) |
| 90 | if len(trimmed) == 0 { |
| 91 | return nil, false |
| 92 | } |
| 93 | |
| 94 | decoder := xml.NewDecoder(bytes.NewReader(trimmed)) |
| 95 | decoder.Strict = true |
| 96 | sanitizer := newSVGTokenSanitizer(limits) |
| 97 | for { |
| 98 | token, err := decoder.Token() |
| 99 | if errors.Is(err, io.EOF) { |
| 100 | break |
| 101 | } |
| 102 | if err != nil { |
| 103 | return nil, false |
| 104 | } |
| 105 | if !sanitizer.accept(token) { |
| 106 | return nil, false |
| 107 | } |
| 108 | } |
| 109 | return sanitizer.finish() |
| 110 | } |
| 111 | |
| 112 | type svgTokenSanitizer struct { |
| 113 | limits svgSanitizeLimits |
| 114 | out bytes.Buffer |
| 115 | encoder *xml.Encoder |
| 116 | rootSeen bool |
| 117 | rootDepth, skipDepth int |
| 118 | elements int |
| 119 | } |
| 120 | |
| 121 | func newSVGTokenSanitizer(limits svgSanitizeLimits) *svgTokenSanitizer { |
| 122 | s := &svgTokenSanitizer{limits: limits} |
| 123 | s.encoder = xml.NewEncoder(&s.out) |
| 124 | return s |
| 125 | } |
| 126 | |
| 127 | func (s *svgTokenSanitizer) accept(token xml.Token) bool { |
| 128 | switch value := token.(type) { |
| 129 | case xml.StartElement: |
| 130 | return s.start(value) |
| 131 | case xml.EndElement: |
| 132 | return s.end(value) |
| 133 | case xml.CharData: |
| 134 | return s.text(value) |
| 135 | case xml.Comment, xml.Directive, xml.ProcInst: |
| 136 | return true |
| 137 | default: |
| 138 | return s.skipDepth > 0 || s.encoder.EncodeToken(value) == nil |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func (s *svgTokenSanitizer) start(value xml.StartElement) bool { |
| 143 | s.elements++ |
| 144 | if s.limits.maxElements > 0 && s.elements > s.limits.maxElements { |
| 145 | return false |
| 146 | } |
| 147 | if s.skipDepth > 0 { |
| 148 | s.skipDepth++ |
| 149 | return true |
| 150 | } |
| 151 | name := strings.ToLower(value.Name.Local) |
| 152 | if !s.acceptRoot(name, value.Name.Space) { |
| 153 | return false |
| 154 | } |
| 155 | if markdownSVGForbiddenElements[name] { |
| 156 | s.skipDepth = 1 |
| 157 | return true |
| 158 | } |
| 159 | value.Attr = safeMarkdownSVGAttributes(value.Attr) |
| 160 | value.Name.Space = outputSVGNamespace(value.Name.Space, s.rootDepth == 0) |
| 161 | s.rootDepth++ |
| 162 | return (s.limits.maxDepth <= 0 || s.rootDepth <= s.limits.maxDepth) && s.encoder.EncodeToken(value) == nil |
| 163 | } |
| 164 | |
| 165 | func (s *svgTokenSanitizer) acceptRoot(name, namespace string) bool { |
| 166 | if s.rootSeen { |
| 167 | return s.rootDepth > 0 |
| 168 | } |
| 169 | if name != "svg" || (namespace != "" && namespace != markdownSVGNamespace) { |
| 170 | return false |
| 171 | } |
| 172 | s.rootSeen = true |
| 173 | return true |
| 174 | } |
| 175 | |
| 176 | func (s *svgTokenSanitizer) end(value xml.EndElement) bool { |
| 177 | if s.skipDepth > 0 { |
| 178 | s.skipDepth-- |
| 179 | return true |
| 180 | } |
| 181 | if s.rootDepth <= 0 { |
| 182 | return false |
| 183 | } |
| 184 | value.Name.Space = outputSVGNamespace(value.Name.Space, s.rootDepth == 1) |
| 185 | if s.encoder.EncodeToken(value) != nil { |
| 186 | return false |
| 187 | } |
| 188 | s.rootDepth-- |
| 189 | return true |
| 190 | } |
| 191 | |
| 192 | func (s *svgTokenSanitizer) text(value xml.CharData) bool { |
| 193 | if s.skipDepth > 0 { |
| 194 | return true |
| 195 | } |
| 196 | if !s.rootSeen || s.rootDepth == 0 { |
| 197 | return len(bytes.TrimSpace(value)) == 0 |
| 198 | } |
| 199 | return s.encoder.EncodeToken(value) == nil |
| 200 | } |
| 201 | |
| 202 | func (s *svgTokenSanitizer) finish() ([]byte, bool) { |
| 203 | if !s.rootSeen || s.rootDepth != 0 || s.skipDepth != 0 || s.encoder.Flush() != nil { |
| 204 | return nil, false |
| 205 | } |
| 206 | return s.out.Bytes(), true |
| 207 | } |
| 208 | |
| 209 | func outputSVGNamespace(namespace string, root bool) string { |
| 210 | if root { |
| 211 | return markdownSVGNamespace |
| 212 | } |
| 213 | if namespace == markdownSVGNamespace { |
| 214 | return "" |
| 215 | } |
| 216 | return namespace |
| 217 | } |
| 218 | |
| 219 | func safeMarkdownSVGAttributes(input []xml.Attr) []xml.Attr { |
| 220 | attrs := input[:0] |
| 221 | for _, attr := range input { |
| 222 | name := strings.ToLower(attr.Name.Local) |
| 223 | unsafeName := isNamespaceDeclaration(attr.Name) || strings.HasPrefix(name, "on") || name == "srcset" || |
| 224 | (attr.Name.Space == "http://www.w3.org/XML/1998/namespace" && name == "base") |
| 225 | if unsafeName { |
| 226 | continue |
| 227 | } |
| 228 | safe := safeMarkdownSVGAttributeValue(attr.Value) |
| 229 | if name == "href" || name == "src" { |
| 230 | safe = safeMarkdownSVGReference(attr.Value) |
| 231 | } |
| 232 | if safe { |
| 233 | attrs = append(attrs, attr) |
| 234 | } |
| 235 | } |
| 236 | return attrs |
| 237 | } |
| 238 | |
| 239 | func safeMarkdownSVGReference(raw string) bool { |
| 240 | value := strings.ToLower(strings.TrimSpace(raw)) |
| 241 | if strings.HasPrefix(value, "#") { |
| 242 | return true |
| 243 | } |
| 244 | for _, prefix := range []string{ |
| 245 | "data:image/png;base64,", |
| 246 | "data:image/jpeg;base64,", |
| 247 | "data:image/gif;base64,", |
| 248 | "data:image/webp;base64,", |
| 249 | "data:image/bmp;base64,", |
| 250 | "data:image/x-icon;base64,", |
| 251 | } { |
| 252 | if strings.HasPrefix(value, prefix) { |
| 253 | return true |
| 254 | } |
| 255 | } |
| 256 | return false |
| 257 | } |
| 258 | |
| 259 | func safeMarkdownSVGAttributeValue(raw string) bool { |
| 260 | if strings.Contains(raw, `\`) { |
| 261 | return false |
| 262 | } |
| 263 | value := strings.ToLower(raw) |
| 264 | if strings.Contains(value, "javascript:") || strings.Contains(value, "vbscript:") || strings.Contains(value, "data:text/html") { |
| 265 | return false |
| 266 | } |
| 267 | for { |
| 268 | index := strings.Index(value, "url(") |
| 269 | if index < 0 { |
| 270 | return !strings.Contains(value, "@import") && !strings.Contains(value, "expression(") |
| 271 | } |
| 272 | value = value[index+4:] |
| 273 | end := strings.IndexByte(value, ')') |
| 274 | if end < 0 { |
| 275 | return false |
| 276 | } |
| 277 | target := strings.Trim(strings.TrimSpace(value[:end]), "\"'") |
| 278 | if !strings.HasPrefix(target, "#") { |
| 279 | return false |
| 280 | } |
| 281 | value = value[end+1:] |
| 282 | } |
| 283 | } |
| 284 |