Skip to content

Commit 3876098

Browse files
committed
feat: implement favicon download functionality with content type validation
1 parent 31acae5 commit 3876098

File tree

2 files changed

+138
-12
lines changed

2 files changed

+138
-12
lines changed

internal/sitecheck/checker.go

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -616,24 +616,77 @@ func (sc *SiteChecker) downloadFavicon(ctx context.Context, faviconURL string) s
616616
return ""
617617
}
618618

619-
// Get content type
620-
contentType := resp.Header.Get("Content-Type")
621-
if contentType == "" {
622-
// Try to determine from URL extension
623-
if strings.HasSuffix(faviconURL, ".png") {
624-
contentType = "image/png"
625-
} else if strings.HasSuffix(faviconURL, ".ico") {
626-
contentType = "image/x-icon"
627-
} else {
628-
contentType = "image/x-icon" // default
629-
}
619+
headerContentType := normalizeContentType(resp.Header.Get("Content-Type"))
620+
inferredContentType := inferContentTypeFromURL(faviconURL)
621+
sniffedContentType := normalizeContentType(http.DetectContentType(body))
622+
623+
contentType := headerContentType
624+
if !isAllowedFaviconContentType(contentType) && isAllowedFaviconContentType(sniffedContentType) {
625+
contentType = sniffedContentType
626+
}
627+
if !isAllowedFaviconContentType(contentType) &&
628+
headerContentType == "" &&
629+
isUnknownContentType(sniffedContentType) &&
630+
isAllowedFaviconContentType(inferredContentType) {
631+
contentType = inferredContentType
632+
}
633+
if !isAllowedFaviconContentType(contentType) {
634+
return ""
630635
}
631636

632-
// Encode as data URL
633637
encoded := base64.StdEncoding.EncodeToString(body)
634638
return fmt.Sprintf("data:%s;base64,%s", contentType, encoded)
635639
}
636640

641+
func normalizeContentType(contentType string) string {
642+
if contentType == "" {
643+
return ""
644+
}
645+
if semi := strings.Index(contentType, ";"); semi != -1 {
646+
contentType = contentType[:semi]
647+
}
648+
return strings.TrimSpace(strings.ToLower(contentType))
649+
}
650+
651+
func inferContentTypeFromURL(faviconURL string) string {
652+
lower := strings.ToLower(faviconURL)
653+
switch {
654+
case strings.HasSuffix(lower, ".png"):
655+
return "image/png"
656+
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
657+
return "image/jpeg"
658+
case strings.HasSuffix(lower, ".webp"):
659+
return "image/webp"
660+
case strings.HasSuffix(lower, ".gif"):
661+
return "image/gif"
662+
case strings.HasSuffix(lower, ".svg"):
663+
return "image/svg+xml"
664+
case strings.HasSuffix(lower, ".ico"):
665+
return "image/x-icon"
666+
default:
667+
return ""
668+
}
669+
}
670+
671+
func isAllowedFaviconContentType(contentType string) bool {
672+
switch contentType {
673+
case "image/png",
674+
"image/jpeg",
675+
"image/webp",
676+
"image/gif",
677+
"image/svg+xml",
678+
"image/x-icon",
679+
"image/vnd.microsoft.icon":
680+
return true
681+
default:
682+
return false
683+
}
684+
}
685+
686+
func isUnknownContentType(contentType string) bool {
687+
return contentType == "" || contentType == "application/octet-stream"
688+
}
689+
637690
// generateDisplayURL generates the URL to display in UI based on health check protocol
638691
func generateDisplayURL(originalURL, protocol string) string {
639692
parsed, err := url.Parse(originalURL)

internal/sitecheck/checker_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package sitecheck
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"io"
57
"net/http"
8+
"net/http/httptest"
9+
"strings"
610
"testing"
711

812
"github.com/0xJacky/Nginx-UI/model"
@@ -46,3 +50,72 @@ func TestCheckSiteSkipsNetworkWhenDisabled(t *testing.T) {
4650
t.Fatalf("CheckSite returned error: %v", err)
4751
}
4852
}
53+
54+
func TestDownloadFaviconAcceptsValidImage(t *testing.T) {
55+
pngData := []byte{
56+
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
57+
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
58+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
59+
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
60+
0xDE, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
61+
0x54, 0x08, 0xD7, 0x63, 0xF8, 0x0F, 0x04, 0x00,
62+
0x09, 0xFB, 0x03, 0xFD, 0xA7, 0x89, 0x81, 0xB9,
63+
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44,
64+
0xAE, 0x42, 0x60, 0x82,
65+
}
66+
67+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68+
w.Header().Set("Content-Type", "image/png")
69+
w.Write(pngData)
70+
}))
71+
defer server.Close()
72+
73+
checker := NewSiteChecker(DefaultCheckOptions())
74+
dataURL := checker.downloadFavicon(context.Background(), server.URL+"/favicon.png")
75+
if dataURL == "" {
76+
t.Fatal("expected data URL for valid favicon")
77+
}
78+
79+
expectedPrefix := "data:image/png;base64,"
80+
if !strings.HasPrefix(dataURL, expectedPrefix) {
81+
t.Fatalf("unexpected data URL prefix: %s", dataURL)
82+
}
83+
84+
expectedPayload := base64.StdEncoding.EncodeToString(pngData)
85+
if payload := strings.TrimPrefix(dataURL, expectedPrefix); payload != expectedPayload {
86+
t.Fatalf("unexpected base64 payload: got %s want %s", payload, expectedPayload)
87+
}
88+
}
89+
90+
func TestDownloadFaviconRejectsHTMLContent(t *testing.T) {
91+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
93+
io.WriteString(w, "<html><body>not an image</body></html>")
94+
}))
95+
defer server.Close()
96+
97+
checker := NewSiteChecker(DefaultCheckOptions())
98+
dataURL := checker.downloadFavicon(context.Background(), server.URL+"/favicon.ico")
99+
if dataURL != "" {
100+
t.Fatalf("expected empty data URL for non-image content, got %s", dataURL)
101+
}
102+
}
103+
104+
func TestDownloadFaviconRejectsHTMLContentWithoutHeader(t *testing.T) {
105+
checker := NewSiteChecker(DefaultCheckOptions())
106+
checker.client = &http.Client{
107+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
108+
return &http.Response{
109+
StatusCode: http.StatusOK,
110+
Header: make(http.Header),
111+
Body: io.NopCloser(strings.NewReader("<html><body>not an image</body></html>")),
112+
Request: req,
113+
}, nil
114+
}),
115+
}
116+
117+
dataURL := checker.downloadFavicon(context.Background(), "http://example.com/favicon.ico")
118+
if dataURL != "" {
119+
t.Fatalf("expected empty data URL when header missing and content sniffing rejects, got %s", dataURL)
120+
}
121+
}

0 commit comments

Comments
 (0)