1// Package favicon provides favicon fetching and caching infrastructure.
2package favicon
3
4import (
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "net"
11 "net/http"
12 "net/netip"
13 "net/url"
14 "strings"
15 "time"
16
17 appport "github.com/bnema/dumber/internal/application/port"
18 domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
19)
20
21const (
22 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
23 fetchTimeout = 5 * time.Second
24 maxFaviconBytes = 1 << 20
25 maxFaviconRedirects = 3
26 sniffLen = 512
27)
28
29type fetchContextKey string
30
31const allowedLocalOriginKey fetchContextKey = "allowed-local-origin"
32
33type Fetcher struct {
34 client *http.Client
35 duckDuckGoURL string
36 maxBytes int64
37}
38
39func NewFetcher() *Fetcher {
40 f := &Fetcher{duckDuckGoURL: duckduckgoFaviconURL, maxBytes: maxFaviconBytes}
41 transport := http.DefaultTransport.(*http.Transport).Clone()
42 transport.DialContext = f.safeDialContext
43 f.client = &http.Client{Timeout: fetchTimeout, Transport: transport, CheckRedirect: f.checkRedirect}
44 return f
45}
46
47func NewFetcherWithClient(client *http.Client, duckDuckGoURL string) *Fetcher {
48 f := &Fetcher{client: client, duckDuckGoURL: duckDuckGoURL, maxBytes: maxFaviconBytes}
49 if f.client == nil {
50 f.client = &http.Client{Timeout: fetchTimeout}
51 }
52 f.client.Transport = f.safeTransport(f.client.Transport)
53 f.client.CheckRedirect = f.checkRedirect
54 if f.duckDuckGoURL == "" {
55 f.duckDuckGoURL = duckduckgoFaviconURL
56 }
57 return f
58}
59
60func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper {
61 transport, ok := base.(*http.Transport)
62 if !ok || transport == nil {
63 transport = http.DefaultTransport.(*http.Transport)
64 }
65 clone := transport.Clone()
66 clone.DialContext = f.safeDialContext
67 return clone
68}
69
70func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
71 fetchURL, resolvedKey, source, err := f.resolveURL(req)
72 if err != nil {
73 return nil, err
74 }
75 data, ct, err := f.fetchURL(ctx, fetchURL, req.PageURL)
76 if err != nil {
77 return nil, err
78 }
79 return &appport.FaviconFetchedIcon{
80 PageURL: req.PageURL,
81 IconURL: fetchURL,
82 ResolvedKey: resolvedKey,
83 Bytes: data,
84 Source: source,
85 ContentType: ct,
86 }, nil
87}
88
89// FetchDomain preserves the old DuckDuckGo-only API for legacy callers.
90func (f *Fetcher) FetchDomain(ctx context.Context, domain string) ([]byte, error) {
91 if strings.TrimSpace(domain) == "" {
92 return nil, nil
93 }
94 got, err := f.Fetch(ctx, appport.FaviconFetchRequest{PageURL: "https://" + domain})
95 if errors.Is(err, appport.ErrFaviconMiss) {
96 return nil, nil
97 }
98 if err != nil {
99 return nil, err
100 }
101 return got.Bytes, nil
102}
103
104func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Key, domainfavicon.Source, error) {
105 if req.IconURL != "" {
106 u, err := url.Parse(req.IconURL)
107 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
108 return "", "", "", appport.ErrFaviconMiss
109 }
110 if !allowedURLForPage(req.PageURL, u) {
111 return "", "", "", appport.ErrFaviconMiss
112 }
113 return u.String(), "", domainfavicon.SourcePageDiscovery, nil
114 }
115 key, ok := domainfavicon.CanonicalHostKey(req.PageURL)
116 if !ok {
117 return "", "", "", appport.ErrFaviconMiss
118 }
119 return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), key, domainfavicon.SourceDuckDuckGo, nil
120}
121
122func (f *Fetcher) fetchURL(ctx context.Context, raw, pageURL string) ([]byte, string, error) {
123 ctx = context.WithValue(ctx, allowedLocalOriginKey, allowedLocalOrigin(pageURL))
124 req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, http.NoBody)
125 if err != nil {
126 return nil, "", err
127 }
128 resp, err := f.client.Do(req)
129 if err != nil {
130 return nil, "", err
131 }
132 defer func() { _ = resp.Body.Close() }()
133 if resp.StatusCode != http.StatusOK {
134 return nil, "", appport.ErrFaviconMiss
135 }
136 limit := f.maxBytes
137 if limit <= 0 {
138 limit = maxFaviconBytes
139 }
140 body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
141 if err != nil {
142 return nil, "", err
143 }
144 if int64(len(body)) > limit {
145 return nil, "", fmt.Errorf("favicon response exceeds %d bytes", limit)
146 }
147 if len(body) == 0 {
148 return nil, "", appport.ErrFaviconMiss
149 }
150 declared := normalizeContentType(resp.Header.Get("Content-Type"))
151 if declared == "" || declared == "application/octet-stream" {
152 declared = normalizeContentType(http.DetectContentType(body[:min(len(body), sniffLen)]))
153 }
154 if !supportedFetchedContentType(declared) || !bodyMatchesContentType(declared, body) {
155 return nil, "", appport.ErrFaviconMiss
156 }
157 return body, declared, nil
158}
159
160func (*Fetcher) checkRedirect(req *http.Request, via []*http.Request) error {
161 if len(via) >= maxFaviconRedirects {
162 return http.ErrUseLastResponse
163 }
164 if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
165 return http.ErrUseLastResponse
166 }
167 if !allowedURLForOrigin(req.Context(), req.URL) {
168 return http.ErrUseLastResponse
169 }
170 return nil
171}
172
173func (*Fetcher) safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
174 host, port, err := net.SplitHostPort(address)
175 if err != nil {
176 return nil, err
177 }
178 allowed := ctx.Value(allowedLocalOriginKey) == normalizeOriginHostPort(host, port, "")
179 ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
180 if err != nil {
181 return nil, err
182 }
183 var dialIP net.IP
184 for _, ip := range ips {
185 addr, ok := netip.AddrFromSlice(ip.IP)
186 if (!ok || isPrivateAddr(addr)) && !allowed {
187 continue
188 }
189 dialIP = ip.IP
190 break
191 }
192 if dialIP == nil {
193 return nil, fmt.Errorf("no allowed resolved address for favicon host %s", host)
194 }
195 var d net.Dialer
196 return d.DialContext(ctx, network, net.JoinHostPort(dialIP.String(), port))
197}
198
199func supportedFetchedContentType(ct string) bool {
200 switch ct {
201 case "image/png", "image/x-icon", "image/vnd.microsoft.icon", "image/jpeg", "image/gif", "image/svg+xml", "image/webp":
202 return true
203 }
204 return false
205}
206
207func bodyMatchesContentType(ct string, body []byte) bool {
208 switch ct {
209 case "image/png":
210 return bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n"))
211 case "image/jpeg":
212 return len(body) >= 3 && body[0] == 0xff && body[1] == 0xd8 && body[2] == 0xff
213 case "image/gif":
214 return bytes.HasPrefix(body, []byte("GIF87a")) || bytes.HasPrefix(body, []byte("GIF89a"))
215 case "image/x-icon", "image/vnd.microsoft.icon":
216 return len(body) >= 4 && body[0] == 0 && body[1] == 0 && (body[2] == 1 || body[2] == 2) && body[3] == 0
217 case "image/webp":
218 return len(body) >= 12 && bytes.HasPrefix(body, []byte("RIFF")) && string(body[8:12]) == "WEBP"
219 case "image/svg+xml":
220 trimmed := bytes.TrimSpace(body)
221 return bytes.HasPrefix(trimmed, []byte("<svg")) || (bytes.HasPrefix(trimmed, []byte("<?xml")) && bytes.Contains(trimmed, []byte("<svg")))
222 default:
223 return false
224 }
225}
226
227func allowedURLForPage(pageURL string, iconURL *url.URL) bool {
228 if !isInternalHost(iconURL.Hostname()) {
229 return true
230 }
231 page, err := url.Parse(pageURL)
232 if err != nil {
233 return false
234 }
235 return isLocalhost(page.Hostname()) && sameOrigin(page, iconURL)
236}
237
238func allowedURLForOrigin(ctx context.Context, target *url.URL) bool {
239 if !isInternalHost(target.Hostname()) {
240 return true
241 }
242 allowed, _ := ctx.Value(allowedLocalOriginKey).(string)
243 targetOrigin := normalizeOriginHostPort(target.Hostname(), target.Port(), target.Scheme)
244 return allowed != "" && strings.EqualFold(allowed, targetOrigin)
245}
246
247func allowedLocalOrigin(pageURL string) string {
248 page, err := url.Parse(pageURL)
249 if err != nil || !isLocalhost(page.Hostname()) {
250 return ""
251 }
252 return normalizeOriginHostPort(page.Hostname(), page.Port(), page.Scheme)
253}
254
255func sameOrigin(a, b *url.URL) bool {
256 return strings.EqualFold(a.Scheme, b.Scheme) &&
257 normalizeOriginHostPort(a.Hostname(), a.Port(), a.Scheme) == normalizeOriginHostPort(b.Hostname(), b.Port(), b.Scheme)
258}
259
260func normalizeOriginHostPort(host, port, scheme string) string {
261 if port == "" {
262 switch strings.ToLower(scheme) {
263 case "http":
264 port = "80"
265 case "https":
266 port = "443"
267 }
268 }
269 if port == "" {
270 return strings.ToLower(host)
271 }
272 return strings.ToLower(net.JoinHostPort(host, port))
273}
274
275func isInternalHost(host string) bool {
276 if isLocalhost(host) {
277 return true
278 }
279 ips, err := net.LookupIP(host)
280 if err != nil {
281 return true
282 }
283 for _, ip := range ips {
284 addr, ok := netip.AddrFromSlice(ip)
285 if !ok || isPrivateAddr(addr) {
286 return true
287 }
288 }
289 return false
290}
291
292func isLocalhost(host string) bool {
293 return strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1"
294}
295
296func isPrivateAddr(addr netip.Addr) bool {
297 return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
298}