返回 DeepSeek-Reasonix
markdown_svg_sanitize.go
根目录 / desktop / markdown_svg_sanitize.go
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
284 lines GO