127 lines
4.4 KiB
Go
127 lines
4.4 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// MaxIconDataURIBytes bounds an uploaded plugin logo. It is deliberately
|
|
// generous: the plugin source itself is capped at 1 MiB, so this only keeps a
|
|
// logo from dwarfing the plugin it decorates.
|
|
const MaxIconDataURIBytes = 512 * 1024
|
|
|
|
var pngSignature = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
|
|
|
// DecodeIconDataURI parses a plugin logo shipped as a sidecar icon.svg or
|
|
// icon.png file and re-encoded by the uploader as
|
|
// data:image/png;base64,... or data:image/svg+xml;base64,.... It returns the
|
|
// media type and the raw image bytes. The admin UI renders logos only through
|
|
// <img>, which already blocks scripts and external loads; the SVG checks here
|
|
// are defense in depth so a hostile payload is rejected at upload instead of
|
|
// being stored.
|
|
func DecodeIconDataURI(icon string) (string, []byte, error) {
|
|
if len(icon) > MaxIconDataURIBytes {
|
|
return "", nil, fmt.Errorf("plugin icon must not exceed %d bytes", MaxIconDataURIBytes)
|
|
}
|
|
mediaType, payload, ok := strings.Cut(strings.TrimPrefix(icon, "data:"), ";base64,")
|
|
if !strings.HasPrefix(icon, "data:") || !ok || (mediaType != "image/png" && mediaType != "image/svg+xml") {
|
|
return "", nil, fmt.Errorf("plugin icon must be data:image/png;base64,... or data:image/svg+xml;base64,...")
|
|
}
|
|
decoded, err := base64.StdEncoding.Strict().DecodeString(payload)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("plugin icon payload is not valid base64")
|
|
}
|
|
if err := ValidateIconImage(mediaType, decoded); err != nil {
|
|
return "", nil, err
|
|
}
|
|
return mediaType, decoded, nil
|
|
}
|
|
|
|
// ValidateIconImage checks raw PNG or SVG bytes for a plugin logo. PNG must
|
|
// carry the PNG signature. SVG must be well-formed XML rooted at svg without
|
|
// script or foreignObject elements, event-handler attributes, directives,
|
|
// javascript: values, or absolute http(s) references.
|
|
func ValidateIconImage(mediaType string, data []byte) error {
|
|
if mediaType == "image/png" {
|
|
if !bytes.HasPrefix(data, pngSignature) {
|
|
return fmt.Errorf("plugin icon PNG payload is not a PNG image")
|
|
}
|
|
return nil
|
|
}
|
|
if mediaType != "image/svg+xml" {
|
|
return fmt.Errorf("plugin icon must be image/png or image/svg+xml")
|
|
}
|
|
decoder := xml.NewDecoder(bytes.NewReader(data))
|
|
rootSeen := false
|
|
styleDepth := 0
|
|
for {
|
|
token, err := decoder.Token()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("plugin icon SVG is not well-formed XML")
|
|
}
|
|
switch element := token.(type) {
|
|
case xml.Directive:
|
|
return fmt.Errorf("plugin icon SVG must not contain a DOCTYPE or other directives")
|
|
case xml.ProcInst:
|
|
if element.Target != "xml" {
|
|
return fmt.Errorf("plugin icon SVG must not contain processing instructions")
|
|
}
|
|
case xml.StartElement:
|
|
local := strings.ToLower(element.Name.Local)
|
|
if !rootSeen {
|
|
if local != "svg" {
|
|
return fmt.Errorf("plugin icon SVG root element must be svg")
|
|
}
|
|
rootSeen = true
|
|
}
|
|
if local == "script" || local == "foreignobject" {
|
|
return fmt.Errorf("plugin icon SVG must not contain %s elements", element.Name.Local)
|
|
}
|
|
if local == "style" {
|
|
styleDepth++
|
|
}
|
|
for _, attr := range element.Attr {
|
|
if attr.Name.Space == "xmlns" || (attr.Name.Space == "" && attr.Name.Local == "xmlns") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(attr.Name.Local), "on") {
|
|
return fmt.Errorf("plugin icon SVG must not contain event handler attributes")
|
|
}
|
|
if svgValueReferencesExternal(attr.Value) {
|
|
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
|
|
}
|
|
}
|
|
case xml.EndElement:
|
|
if strings.ToLower(element.Name.Local) == "style" && styleDepth > 0 {
|
|
styleDepth--
|
|
}
|
|
case xml.CharData:
|
|
if styleDepth > 0 && (svgValueReferencesExternal(string(element)) || strings.Contains(strings.ToLower(string(element)), "@import")) {
|
|
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
|
|
}
|
|
}
|
|
}
|
|
if !rootSeen {
|
|
return fmt.Errorf("plugin icon SVG root element must be svg")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func svgValueReferencesExternal(value string) bool {
|
|
compact := strings.Map(func(character rune) rune {
|
|
if unicode.IsSpace(character) || unicode.IsControl(character) {
|
|
return -1
|
|
}
|
|
return unicode.ToLower(character)
|
|
}, value)
|
|
return strings.Contains(compact, "javascript:") || strings.Contains(compact, "http://") || strings.Contains(compact, "https://")
|
|
}
|