Merge pull request 'fix(favicon): rasterize SVG favicons instead of rejecting them' (#14) from fix/favicon-svg-raster into main
Reviewed-on: https://git.bnema.dev/bnema/dumber/pulls/14
5 files · +175 -2
M go.mod +2 -0
| @@ -23,6 +23,8 @@ require ( | |||
| 23 | 23 | github.com/spf13/cobra v1.10.2 | |
| 24 | 24 | github.com/spf13/pflag v1.0.10 | |
| 25 | 25 | github.com/spf13/viper v1.21.0 | |
| 26 | + | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c | |
| 27 | + | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef | |
| 26 | 28 | github.com/stretchr/testify v1.12.1 | |
| 27 | 29 | golang.org/x/image v0.47.0 | |
| 28 | 30 | golang.org/x/net v0.61.0 | |
| @@ -23,6 +23,8 @@ require ( | |||||
| 23 | github.com/spf13/cobra v1.10.2 | 23 | github.com/spf13/cobra v1.10.2 | ||
| 24 | github.com/spf13/pflag v1.0.10 | 24 | github.com/spf13/pflag v1.0.10 | ||
| 25 | github.com/spf13/viper v1.21.0 | 25 | github.com/spf13/viper v1.21.0 | ||
| 26 | + | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c | |||
| 27 | + | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef | |||
| 26 | github.com/stretchr/testify v1.12.1 | 28 | github.com/stretchr/testify v1.12.1 | ||
| 27 | golang.org/x/image v0.47.0 | 29 | golang.org/x/image v0.47.0 | ||
| 28 | golang.org/x/net v0.61.0 | 30 | golang.org/x/net v0.61.0 | ||
M go.sum +4 -0 generated file: collapsed
M internal/infrastructure/favicon/converter.go +1 -1
| @@ -59,7 +59,7 @@ func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentTy | |||
| 59 | 59 | func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) { | |
| 60 | 60 | switch ct { | |
| 61 | 61 | case "image/svg+xml": | |
| 62 | - | return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss) | |
| 62 | + | return rasterizeSVG(data) | |
| 63 | 63 | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | |
| 64 | 64 | img, err := decodeICO(data) | |
| 65 | 65 | if err != nil { | |
| @@ -59,7 +59,7 @@ func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentTy | |||||
| 59 | func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) { | 59 | func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) { | ||
| 60 | switch ct { | 60 | switch ct { | ||
| 61 | case "image/svg+xml": | 61 | case "image/svg+xml": | ||
| 62 | - | return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss) | 62 | + | return rasterizeSVG(data) |
| 63 | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | 63 | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | ||
| 64 | img, err := decodeICO(data) | 64 | img, err := decodeICO(data) | ||
| 65 | if err != nil { | 65 | if err != nil { | ||
M internal/infrastructure/favicon/converter_test.go +52 -1
| @@ -65,8 +65,8 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) { | |||
| 65 | 65 | name, ct string | |
| 66 | 66 | data []byte | |
| 67 | 67 | }{ | |
| 68 | - | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |
| 69 | 68 | {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | |
| 69 | + | {"empty svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"></svg>`)}, | |
| 70 | 70 | } { | |
| 71 | 71 | t.Run(tc.name, func(t *testing.T) { | |
| 72 | 72 | if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| @@ -76,6 +76,57 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) { | |||
| 76 | 76 | } | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | + | func TestConverterRasterizesSVG(t *testing.T) { | |
| 80 | + | conv := NewImageConverter() | |
| 81 | + | svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | |
| 82 | + | <rect x="0" y="0" width="32" height="16" fill="#ff0000"/> | |
| 83 | + | <rect x="0" y="16" width="32" height="16" fill="#0000ff"/> | |
| 84 | + | </svg>`) | |
| 85 | + | out, err := conv.Convert(context.Background(), svg, "image/svg+xml", []int{32}) | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | img, err := png.Decode(bytes.NewReader(out.SizedPNG[32])) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 { | |
| 94 | + | t.Fatalf("size = %v", img.Bounds()) | |
| 95 | + | } | |
| 96 | + | r, _, b, a := img.At(16, 4).RGBA() | |
| 97 | + | if a == 0 || r>>8 < 200 || b>>8 > 50 { | |
| 98 | + | t.Fatalf("top pixel = r%d b%d a%d, want red", r>>8, b>>8, a>>8) | |
| 99 | + | } | |
| 100 | + | r, _, b, a = img.At(16, 28).RGBA() | |
| 101 | + | if a == 0 || b>>8 < 200 || r>>8 > 50 { | |
| 102 | + | t.Fatalf("bottom pixel = r%d b%d a%d, want blue", r>>8, b>>8, a>>8) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestConverterRejectsRecursiveSVGUse(t *testing.T) { | |
| 107 | + | conv := NewImageConverter() | |
| 108 | + | for _, tc := range []struct{ name, svg string }{ | |
| 109 | + | {"self cycle", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"><use href="#a"/></g></defs><use href="#a"/></svg>`}, | |
| 110 | + | {"mutual cycle", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><defs><g id="a"><use xlink:href="#b"/></g><g id="b"><use xlink:href="#a"/></g></defs><use xlink:href="#a"/></svg>`}, | |
| 111 | + | {"use targets use", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use id="a" href="#a"/></defs><use href="#a"/></svg>`}, | |
| 112 | + | {"flat def run after sibling id", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"></g><use href="#a"/></defs><use href="#a"/></svg>`}, | |
| 113 | + | {"anonymous def", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use href="#"/></defs><use href="#"/></svg>`}, | |
| 114 | + | } { | |
| 115 | + | t.Run(tc.name, func(t *testing.T) { | |
| 116 | + | if _, err := conv.Convert(context.Background(), []byte(tc.svg), "image/svg+xml", []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 117 | + | t.Fatalf("err = %v, want favicon miss", err) | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestConverterRendersFlatSVGUse(t *testing.T) { | |
| 124 | + | svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><rect id="r" width="16" height="16" fill="#00ff00"/></defs><use href="#r"/></svg>`) | |
| 125 | + | if _, err := NewImageConverter().Convert(context.Background(), svg, "image/svg+xml", []int{32}); err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 79 | 130 | func TestConverterMalformedInputsAreMisses(t *testing.T) { | |
| 80 | 131 | conv := NewImageConverter() | |
| 81 | 132 | for _, tc := range []struct { | |
| @@ -65,8 +65,8 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) { | |||||
| 65 | name, ct string | 65 | name, ct string | ||
| 66 | data []byte | 66 | data []byte | ||
| 67 | }{ | 67 | }{ | ||
| 68 | - | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |||
| 69 | {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | 68 | {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | ||
| 69 | + | {"empty svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"></svg>`)}, | |||
| 70 | } { | 70 | } { | ||
| 71 | t.Run(tc.name, func(t *testing.T) { | 71 | t.Run(tc.name, func(t *testing.T) { | ||
| 72 | if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | 72 | if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | ||
| @@ -76,6 +76,57 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) { | |||||
| 76 | } | 76 | } | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | + | func TestConverterRasterizesSVG(t *testing.T) { | |||
| 80 | + | conv := NewImageConverter() | |||
| 81 | + | svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | |||
| 82 | + | <rect x="0" y="0" width="32" height="16" fill="#ff0000"/> | |||
| 83 | + | <rect x="0" y="16" width="32" height="16" fill="#0000ff"/> | |||
| 84 | + | </svg>`) | |||
| 85 | + | out, err := conv.Convert(context.Background(), svg, "image/svg+xml", []int{32}) | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | img, err := png.Decode(bytes.NewReader(out.SizedPNG[32])) | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatal(err) | |||
| 92 | + | } | |||
| 93 | + | if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 { | |||
| 94 | + | t.Fatalf("size = %v", img.Bounds()) | |||
| 95 | + | } | |||
| 96 | + | r, _, b, a := img.At(16, 4).RGBA() | |||
| 97 | + | if a == 0 || r>>8 < 200 || b>>8 > 50 { | |||
| 98 | + | t.Fatalf("top pixel = r%d b%d a%d, want red", r>>8, b>>8, a>>8) | |||
| 99 | + | } | |||
| 100 | + | r, _, b, a = img.At(16, 28).RGBA() | |||
| 101 | + | if a == 0 || b>>8 < 200 || r>>8 > 50 { | |||
| 102 | + | t.Fatalf("bottom pixel = r%d b%d a%d, want blue", r>>8, b>>8, a>>8) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestConverterRejectsRecursiveSVGUse(t *testing.T) { | |||
| 107 | + | conv := NewImageConverter() | |||
| 108 | + | for _, tc := range []struct{ name, svg string }{ | |||
| 109 | + | {"self cycle", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"><use href="#a"/></g></defs><use href="#a"/></svg>`}, | |||
| 110 | + | {"mutual cycle", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><defs><g id="a"><use xlink:href="#b"/></g><g id="b"><use xlink:href="#a"/></g></defs><use xlink:href="#a"/></svg>`}, | |||
| 111 | + | {"use targets use", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use id="a" href="#a"/></defs><use href="#a"/></svg>`}, | |||
| 112 | + | {"flat def run after sibling id", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"></g><use href="#a"/></defs><use href="#a"/></svg>`}, | |||
| 113 | + | {"anonymous def", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use href="#"/></defs><use href="#"/></svg>`}, | |||
| 114 | + | } { | |||
| 115 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 116 | + | if _, err := conv.Convert(context.Background(), []byte(tc.svg), "image/svg+xml", []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | |||
| 117 | + | t.Fatalf("err = %v, want favicon miss", err) | |||
| 118 | + | } | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestConverterRendersFlatSVGUse(t *testing.T) { | |||
| 124 | + | svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><rect id="r" width="16" height="16" fill="#00ff00"/></defs><use href="#r"/></svg>`) | |||
| 125 | + | if _, err := NewImageConverter().Convert(context.Background(), svg, "image/svg+xml", []int{32}); err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 79 | func TestConverterMalformedInputsAreMisses(t *testing.T) { | 130 | func TestConverterMalformedInputsAreMisses(t *testing.T) { | ||
| 80 | conv := NewImageConverter() | 131 | conv := NewImageConverter() | ||
| 81 | for _, tc := range []struct { | 132 | for _, tc := range []struct { | ||
A internal/infrastructure/favicon/svg.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/xml" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "image" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/srwiley/oksvg" | |
| 13 | + | "github.com/srwiley/rasterx" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // svgRasterSize is the square size SVG favicons are rendered at before the | |
| 17 | + | // normal PNG normalization and resizing steps. | |
| 18 | + | const svgRasterSize = 128 | |
| 19 | + | ||
| 20 | + | // rasterizeSVG renders an SVG favicon to a square RGBA image. The parser only | |
| 21 | + | // reads the given bytes: it never resolves external references. | |
| 22 | + | func rasterizeSVG(data []byte) (img image.Image, err error) { | |
| 23 | + | // The SVG parser handles untrusted page content; a parser panic must | |
| 24 | + | // degrade to a favicon miss instead of crashing the browser. | |
| 25 | + | defer func() { | |
| 26 | + | if r := recover(); r != nil { | |
| 27 | + | img = nil | |
| 28 | + | err = fmt.Errorf("%w: svg favicon render panic: %v", appport.ErrFaviconMiss, r) | |
| 29 | + | } | |
| 30 | + | }() | |
| 31 | + | trimmed := bytes.TrimSpace(data) | |
| 32 | + | if !bytes.Contains(trimmed, []byte("<svg")) { | |
| 33 | + | return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss) | |
| 34 | + | } | |
| 35 | + | if validateErr := validateSVGStructure(trimmed); validateErr != nil { | |
| 36 | + | return nil, validateErr | |
| 37 | + | } | |
| 38 | + | icon, err := oksvg.ReadIconStream(bytes.NewReader(trimmed), oksvg.IgnoreErrorMode) | |
| 39 | + | if err != nil { | |
| 40 | + | return nil, fmt.Errorf("%w: parse svg favicon: %w", appport.ErrFaviconMiss, err) | |
| 41 | + | } | |
| 42 | + | if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 { | |
| 43 | + | return nil, fmt.Errorf("%w: svg favicon has no usable size", appport.ErrFaviconMiss) | |
| 44 | + | } | |
| 45 | + | dst := image.NewRGBA(image.Rect(0, 0, svgRasterSize, svgRasterSize)) | |
| 46 | + | icon.SetTarget(0, 0, svgRasterSize, svgRasterSize) | |
| 47 | + | scanner := rasterx.NewScannerGV(svgRasterSize, svgRasterSize, dst, dst.Bounds()) | |
| 48 | + | icon.Draw(rasterx.NewDasher(svgRasterSize, svgRasterSize, scanner), 1) | |
| 49 | + | if isFullyTransparent(dst) { | |
| 50 | + | return nil, fmt.Errorf("%w: svg favicon rendered empty", appport.ErrFaviconMiss) | |
| 51 | + | } | |
| 52 | + | return dst, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | const ( | |
| 56 | + | // maxSVGUseElements bounds <use> expansion work for one favicon. | |
| 57 | + | maxSVGUseElements = 64 | |
| 58 | + | // maxSVGDepth bounds element nesting depth. | |
| 59 | + | maxSVGDepth = 64 | |
| 60 | + | ) | |
| 61 | + | ||
| 62 | + | // validateSVGStructure rejects SVGs that could make the renderer recurse | |
| 63 | + | // without bound. oksvg expands <use> references recursively with no cycle | |
| 64 | + | // detection, and a runtime stack overflow cannot be recovered. oksvg only | |
| 65 | + | // resolves <use> targets from <defs> content, so forbidding <use> inside | |
| 66 | + | // <defs> limits expansion to one level and rules out cycles. The decoder | |
| 67 | + | // is strict like oksvg's, so both see the same element tree. | |
| 68 | + | func validateSVGStructure(data []byte) error { | |
| 69 | + | dec := xml.NewDecoder(bytes.NewReader(data)) | |
| 70 | + | depth, defsDepth, uses := 0, 0, 0 | |
| 71 | + | for { | |
| 72 | + | tok, err := dec.Token() | |
| 73 | + | if errors.Is(err, io.EOF) { | |
| 74 | + | break | |
| 75 | + | } | |
| 76 | + | if err != nil { | |
| 77 | + | return fmt.Errorf("%w: malformed svg: %w", appport.ErrFaviconMiss, err) | |
| 78 | + | } | |
| 79 | + | switch t := tok.(type) { | |
| 80 | + | case xml.StartElement: | |
| 81 | + | depth++ | |
| 82 | + | if depth > maxSVGDepth { | |
| 83 | + | return fmt.Errorf("%w: svg favicon nested too deeply", appport.ErrFaviconMiss) | |
| 84 | + | } | |
| 85 | + | switch t.Name.Local { | |
| 86 | + | case "defs": | |
| 87 | + | if defsDepth == 0 { | |
| 88 | + | defsDepth = depth | |
| 89 | + | } | |
| 90 | + | case "use": | |
| 91 | + | if defsDepth > 0 { | |
| 92 | + | return fmt.Errorf("%w: svg favicon has <use> inside <defs>", appport.ErrFaviconMiss) | |
| 93 | + | } | |
| 94 | + | uses++ | |
| 95 | + | if uses > maxSVGUseElements { | |
| 96 | + | return fmt.Errorf("%w: svg favicon has too many <use> elements", appport.ErrFaviconMiss) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | case xml.EndElement: | |
| 100 | + | if depth == defsDepth { | |
| 101 | + | defsDepth = 0 | |
| 102 | + | } | |
| 103 | + | depth-- | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func isFullyTransparent(img *image.RGBA) bool { | |
| 110 | + | for i := 3; i < len(img.Pix); i += 4 { | |
| 111 | + | if img.Pix[i] != 0 { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return true | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package favicon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/xml" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "image" | |||
| 9 | + | "io" | |||
| 10 | + | ||||
| 11 | + | appport "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | "github.com/srwiley/oksvg" | |||
| 13 | + | "github.com/srwiley/rasterx" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // svgRasterSize is the square size SVG favicons are rendered at before the | |||
| 17 | + | // normal PNG normalization and resizing steps. | |||
| 18 | + | const svgRasterSize = 128 | |||
| 19 | + | ||||
| 20 | + | // rasterizeSVG renders an SVG favicon to a square RGBA image. The parser only | |||
| 21 | + | // reads the given bytes: it never resolves external references. | |||
| 22 | + | func rasterizeSVG(data []byte) (img image.Image, err error) { | |||
| 23 | + | // The SVG parser handles untrusted page content; a parser panic must | |||
| 24 | + | // degrade to a favicon miss instead of crashing the browser. | |||
| 25 | + | defer func() { | |||
| 26 | + | if r := recover(); r != nil { | |||
| 27 | + | img = nil | |||
| 28 | + | err = fmt.Errorf("%w: svg favicon render panic: %v", appport.ErrFaviconMiss, r) | |||
| 29 | + | } | |||
| 30 | + | }() | |||
| 31 | + | trimmed := bytes.TrimSpace(data) | |||
| 32 | + | if !bytes.Contains(trimmed, []byte("<svg")) { | |||
| 33 | + | return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss) | |||
| 34 | + | } | |||
| 35 | + | if validateErr := validateSVGStructure(trimmed); validateErr != nil { | |||
| 36 | + | return nil, validateErr | |||
| 37 | + | } | |||
| 38 | + | icon, err := oksvg.ReadIconStream(bytes.NewReader(trimmed), oksvg.IgnoreErrorMode) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return nil, fmt.Errorf("%w: parse svg favicon: %w", appport.ErrFaviconMiss, err) | |||
| 41 | + | } | |||
| 42 | + | if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 { | |||
| 43 | + | return nil, fmt.Errorf("%w: svg favicon has no usable size", appport.ErrFaviconMiss) | |||
| 44 | + | } | |||
| 45 | + | dst := image.NewRGBA(image.Rect(0, 0, svgRasterSize, svgRasterSize)) | |||
| 46 | + | icon.SetTarget(0, 0, svgRasterSize, svgRasterSize) | |||
| 47 | + | scanner := rasterx.NewScannerGV(svgRasterSize, svgRasterSize, dst, dst.Bounds()) | |||
| 48 | + | icon.Draw(rasterx.NewDasher(svgRasterSize, svgRasterSize, scanner), 1) | |||
| 49 | + | if isFullyTransparent(dst) { | |||
| 50 | + | return nil, fmt.Errorf("%w: svg favicon rendered empty", appport.ErrFaviconMiss) | |||
| 51 | + | } | |||
| 52 | + | return dst, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | const ( | |||
| 56 | + | // maxSVGUseElements bounds <use> expansion work for one favicon. | |||
| 57 | + | maxSVGUseElements = 64 | |||
| 58 | + | // maxSVGDepth bounds element nesting depth. | |||
| 59 | + | maxSVGDepth = 64 | |||
| 60 | + | ) | |||
| 61 | + | ||||
| 62 | + | // validateSVGStructure rejects SVGs that could make the renderer recurse | |||
| 63 | + | // without bound. oksvg expands <use> references recursively with no cycle | |||
| 64 | + | // detection, and a runtime stack overflow cannot be recovered. oksvg only | |||
| 65 | + | // resolves <use> targets from <defs> content, so forbidding <use> inside | |||
| 66 | + | // <defs> limits expansion to one level and rules out cycles. The decoder | |||
| 67 | + | // is strict like oksvg's, so both see the same element tree. | |||
| 68 | + | func validateSVGStructure(data []byte) error { | |||
| 69 | + | dec := xml.NewDecoder(bytes.NewReader(data)) | |||
| 70 | + | depth, defsDepth, uses := 0, 0, 0 | |||
| 71 | + | for { | |||
| 72 | + | tok, err := dec.Token() | |||
| 73 | + | if errors.Is(err, io.EOF) { | |||
| 74 | + | break | |||
| 75 | + | } | |||
| 76 | + | if err != nil { | |||
| 77 | + | return fmt.Errorf("%w: malformed svg: %w", appport.ErrFaviconMiss, err) | |||
| 78 | + | } | |||
| 79 | + | switch t := tok.(type) { | |||
| 80 | + | case xml.StartElement: | |||
| 81 | + | depth++ | |||
| 82 | + | if depth > maxSVGDepth { | |||
| 83 | + | return fmt.Errorf("%w: svg favicon nested too deeply", appport.ErrFaviconMiss) | |||
| 84 | + | } | |||
| 85 | + | switch t.Name.Local { | |||
| 86 | + | case "defs": | |||
| 87 | + | if defsDepth == 0 { | |||
| 88 | + | defsDepth = depth | |||
| 89 | + | } | |||
| 90 | + | case "use": | |||
| 91 | + | if defsDepth > 0 { | |||
| 92 | + | return fmt.Errorf("%w: svg favicon has <use> inside <defs>", appport.ErrFaviconMiss) | |||
| 93 | + | } | |||
| 94 | + | uses++ | |||
| 95 | + | if uses > maxSVGUseElements { | |||
| 96 | + | return fmt.Errorf("%w: svg favicon has too many <use> elements", appport.ErrFaviconMiss) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | case xml.EndElement: | |||
| 100 | + | if depth == defsDepth { | |||
| 101 | + | defsDepth = 0 | |||
| 102 | + | } | |||
| 103 | + | depth-- | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | return nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func isFullyTransparent(img *image.RGBA) bool { | |||
| 110 | + | for i := 3; i < len(img.Pix); i += 4 { | |||
| 111 | + | if img.Pix[i] != 0 { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | return true | |||
| 116 | + | } | |||