clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / internal / daemon / custom_views_validation.go
custom_views_validation.go 6.1K
1package daemon23import (4 "bytes"5 "encoding/base64"6 "encoding/xml"7 "errors"8 "image/png"9 "io"10 "strings"11)1213var pngSignature = []byte("\x89PNG\r\n\x1a\n")1415const maxArtifactPixels = int64(16 * 1024 * 1024)1617func 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 "", err25 }26 return artifact.Body, nil27 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), nil54 default:55 return "", errors.New("invalid: artifact type must be image/svg+xml or image/png")56 }57}5859func checkSVG(body string) error {60 decoder := xml.NewDecoder(strings.NewReader(body))61 depth, roots, styleDepth := 0, 0, 062 var style strings.Builder63 for {64 token, err := decoder.Token()65 if errors.Is(err, io.EOF) {66 break67 }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 + 1103 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 = 0133 }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 nil148}149150func 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}153154func safeSVGCSSText(value string) bool {155 clean, ok := stripCSSComments(value)156 if !ok || strings.Contains(clean, "\\") {157 return false158 }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}163164func stripCSSComments(value string) (string, bool) {165 var out strings.Builder166 for len(value) > 0 {167 start := strings.Index(value, "/*")168 if start < 0 {169 out.WriteString(value)170 return out.String(), true171 }172 out.WriteString(value[:start])173 value = value[start+2:]174 end := strings.Index(value, "*/")175 if end < 0 {176 return "", false177 }178 out.WriteByte(' ')179 value = value[end+2:]180 }181 return out.String(), true182}183184func localSVGURLs(value string) bool {185 lower := strings.ToLower(value)186 for {187 at := strings.Index(lower, "url(")188 if at < 0 {189 return true190 }191 end := strings.IndexByte(lower[at+4:], ')')192 if end < 0 {193 return false194 }195 target := strings.Trim(strings.TrimSpace(value[at+4:at+4+end]), "\"'")196 if !strings.HasPrefix(target, "#") {197 return false198 }199 lower = lower[at+4+end+1:]200 value = value[at+4+end+1:]201 }202}