tinyrelayfile
tinyrelay/internal/daemon/custom_views_validation.go/

internal/daemon/custom_views_validation.go raw
1package daemon
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/xml"
7 "errors"
8 "image/png"
9 "io"
10 "strings"
11)
12
13var pngSignature = []byte("\x89PNG\r\n\x1a\n")
14
15const maxArtifactPixels = int64(16 * 1024 * 1024)
16
17func checkArtifact(artifact viewArtifact, maxBytes int) (string, error) {
18 switch artifact.Type {
19 case "image/svg+xml":
20 if len(artifact.Body) > maxBytes {
21 return "", errors.New("invalid: artifact exceeds the size limit")
22 }
23 if err := checkSVG(artifact.Body); err != nil {
24 return "", err
25 }
26 return artifact.Body, nil
27 case "image/png":
28 body := strings.TrimSpace(artifact.Body)
29 decoded, err := base64.StdEncoding.DecodeString(body)
30 if err != nil {
31 decoded, err = base64.RawStdEncoding.DecodeString(body)
32 }
33 if err != nil {
34 return "", errors.New("invalid: png body must be base64")
35 }
36 if len(decoded) > maxBytes {
37 return "", errors.New("invalid: artifact exceeds the size limit")
38 }
39 if !bytes.HasPrefix(decoded, pngSignature) {
40 return "", errors.New("invalid: png body is not a PNG")
41 }
42 config, err := png.DecodeConfig(bytes.NewReader(decoded))
43 if err != nil || config.Width <= 0 || config.Height <= 0 {
44 return "", errors.New("invalid: png body is not a valid PNG")
45 }
46 pixels := int64(config.Width) * int64(config.Height)
47 if int64(config.Width) > maxArtifactPixels || int64(config.Height) > maxArtifactPixels || pixels > maxArtifactPixels {
48 return "", errors.New("invalid: png dimensions exceed the limit")
49 }
50 if _, err := png.Decode(bytes.NewReader(decoded)); err != nil {
51 return "", errors.New("invalid: png body is not a valid PNG")
52 }
53 return base64.StdEncoding.EncodeToString(decoded), nil
54 default:
55 return "", errors.New("invalid: artifact type must be image/svg+xml or image/png")
56 }
57}
58
59func checkSVG(body string) error {
60 decoder := xml.NewDecoder(strings.NewReader(body))
61 depth, roots, styleDepth := 0, 0, 0
62 var style strings.Builder
63 for {
64 token, err := decoder.Token()
65 if errors.Is(err, io.EOF) {
66 break
67 }
68 if err != nil {
69 return errors.New("invalid: svg is not well-formed XML")
70 }
71 switch token := token.(type) {
72 case xml.Directive:
73 return errors.New("invalid: svg contains a declaration")
74 case xml.ProcInst:
75 if strings.EqualFold(token.Target, "xml-stylesheet") {
76 return errors.New("invalid: svg contains a stylesheet instruction")
77 }
78 case xml.StartElement:
79 if styleDepth > 0 {
80 return errors.New("invalid: svg style contains an element")
81 }
82 name := strings.ToLower(token.Name.Local)
83 if depth == 0 {
84 roots++
85 if name != "svg" {
86 return errors.New("invalid: svg body has no svg element")
87 }
88 }
89 if token.Name.Space != "" && token.Name.Space != "http://www.w3.org/2000/svg" {
90 return errors.New("invalid: svg has an invalid namespace")
91 }
92 if name == "script" {
93 return errors.New("invalid: svg contains script")
94 }
95 if name == "foreignobject" {
96 return errors.New("invalid: svg contains a foreign object")
97 }
98 if name == "iframe" || name == "object" || name == "embed" || name == "animate" || name == "set" || name == "animatemotion" || name == "animatetransform" {
99 return errors.New("invalid: svg contains active content")
100 }
101 if name == "style" {
102 styleDepth = depth + 1
103 style.Reset()
104 }
105 for _, attr := range token.Attr {
106 attrName := strings.ToLower(attr.Name.Local)
107 if attrName == "base" && (attr.Name.Space == "xml" || attr.Name.Space == "http://www.w3.org/XML/1998/namespace") {
108 return errors.New("invalid: svg has an external base")
109 }
110 if strings.HasPrefix(attrName, "on") && len(attrName) > 2 {
111 return errors.New("invalid: svg contains an event handler")
112 }
113 value := strings.TrimSpace(attr.Value)
114 if attrName == "href" || attrName == "src" {
115 if value == "" || !strings.HasPrefix(value, "#") {
116 if strings.HasPrefix(strings.ToLower(value), "javascript:") {
117 return errors.New("invalid: svg contains a javascript reference")
118 }
119 return errors.New("invalid: svg references an external target")
120 }
121 }
122 if isCSSAttribute(attrName) && !safeSVGCSSText(value) {
123 return errors.New("invalid: svg references an external target")
124 }
125 }
126 depth++
127 case xml.EndElement:
128 if styleDepth == depth {
129 if !safeSVGCSSText(style.String()) {
130 return errors.New("invalid: svg references an external target")
131 }
132 styleDepth = 0
133 }
134 depth--
135 case xml.CharData:
136 if depth == 0 && strings.TrimSpace(string(token)) != "" {
137 return errors.New("invalid: svg has content outside its root element")
138 }
139 if styleDepth > 0 {
140 style.Write([]byte(token))
141 }
142 }
143 }
144 if roots != 1 || depth != 0 || styleDepth != 0 {
145 return errors.New("invalid: svg is not well-formed XML")
146 }
147 return nil
148}
149
150func isCSSAttribute(name string) bool {
151 return name == "style" || strings.Contains(name, "fill") || strings.Contains(name, "stroke") || strings.Contains(name, "filter") || strings.Contains(name, "mask") || strings.Contains(name, "clip-path") || strings.Contains(name, "marker-")
152}
153
154func safeSVGCSSText(value string) bool {
155 clean, ok := stripCSSComments(value)
156 if !ok || strings.Contains(clean, "\\") {
157 return false
158 }
159 lower := strings.ToLower(clean)
160 compact := strings.ReplaceAll(lower, " ", "")
161 return !strings.Contains(lower, "@import") && !strings.Contains(compact, "@import") && localSVGURLs(clean) && localSVGURLs(compact)
162}
163
164func stripCSSComments(value string) (string, bool) {
165 var out strings.Builder
166 for len(value) > 0 {
167 start := strings.Index(value, "/*")
168 if start < 0 {
169 out.WriteString(value)
170 return out.String(), true
171 }
172 out.WriteString(value[:start])
173 value = value[start+2:]
174 end := strings.Index(value, "*/")
175 if end < 0 {
176 return "", false
177 }
178 out.WriteByte(' ')
179 value = value[end+2:]
180 }
181 return out.String(), true
182}
183
184func localSVGURLs(value string) bool {
185 lower := strings.ToLower(value)
186 for {
187 at := strings.Index(lower, "url(")
188 if at < 0 {
189 return true
190 }
191 end := strings.IndexByte(lower[at+4:], ')')
192 if end < 0 {
193 return false
194 }
195 target := strings.Trim(strings.TrimSpace(value[at+4:at+4+end]), "\"'")
196 if !strings.HasPrefix(target, "#") {
197 return false
198 }
199 lower = lower[at+4+end+1:]
200 value = value[at+4+end+1:]
201 }
202}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs