fix(favicon): rasterize SVG favicons instead of rejecting them
Sites whose only icon link is SVG never stored their own favicon, so subdomains kept showing the parent domain icon. Render SVG with oksvg, and reject <use> inside <defs> plus deep or oversized trees so cyclic references cannot overflow the stack.
- commit
- a5fd538bb80d9af5d7ea2feb3a8573e8c069d8ef
- author
- brice · · 2026-10-18 06:36 UTC
- parent
- edd8138
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
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
| @@ -133,6 +133,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||
| 133 | 133 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 134 | 134 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | |
| 135 | 135 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | |
| 136 | + | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= | |
| 137 | + | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= | |
| 138 | + | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= | |
| 139 | + | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= | |
| 136 | 140 | github.com/stretchr/objx v0.7.0 h1:vyXn+M0i636+5W3S8uu9xLCMODOUu4cRJvByx7EjOXo= | |
| 137 | 141 | github.com/stretchr/objx v0.7.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE= | |
| 138 | 142 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= | |
| @@ -133,6 +133,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||||
| 133 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 133 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||
| 134 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | 134 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | ||
| 135 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | 135 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | ||
| 136 | + | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= | |||
| 137 | + | github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= | |||
| 138 | + | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= | |||
| 139 | + | github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= | |||
| 136 | github.com/stretchr/objx v0.7.0 h1:vyXn+M0i636+5W3S8uu9xLCMODOUu4cRJvByx7EjOXo= | 140 | github.com/stretchr/objx v0.7.0 h1:vyXn+M0i636+5W3S8uu9xLCMODOUu4cRJvByx7EjOXo= | ||
| 137 | github.com/stretchr/objx v0.7.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE= | 141 | github.com/stretchr/objx v0.7.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE= | ||
| 138 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= | 142 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= | ||
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 | + | } | |||