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 // Classified miss: callers keep ErrFaviconMiss matching via Is,
135 // while the use case can distinguish genuine absence (404/410)
136 // from auth/transient failures for negative caching.
137 return nil, "", &appport.FaviconFetchError{StatusCode: resp.StatusCode}
138 }
139 limit := f.maxBytes
140 if limit <= 0 {
141 limit = maxFaviconBytes
142 }
143 body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
144 if err != nil {
145 return nil, "", err
146 }
147 if int64(len(body)) > limit {
148 return nil, "", fmt.Errorf("favicon response exceeds %d bytes", limit)
149 }
150 if len(body) == 0 {
151 return nil, "", appport.ErrFaviconMiss
152 }
153 declared := normalizeContentType(resp.Header.Get("Content-Type"))
154 if declared == "" || declared == "application/octet-stream" {
155 declared = normalizeContentType(http.DetectContentType(body[:min(len(body), sniffLen)]))
156 }
157 if !supportedFetchedContentType(declared) || !bodyMatchesContentType(declared, body) {
158 return nil, "", appport.ErrFaviconMiss
159 }
160 return body, declared, nil
161}
162
163func (*Fetcher) checkRedirect(req *http.Request, via []*http.Request) error {
164 if len(via) >= maxFaviconRedirects {
165 return http.ErrUseLastResponse
166 }
167 if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
168 return http.ErrUseLastResponse
169 }
170 if !allowedURLForOrigin(req.Context(), req.URL) {
171 return http.ErrUseLastResponse
172 }
173 return nil
174}
175
176func (*Fetcher) safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
177 host, port, err := net.SplitHostPort(address)
178 if err != nil {
179 return nil, err
180 }
181 allowed := ctx.Value(allowedLocalOriginKey) == normalizeOriginHostPort(host, port, "")
182 ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
183 if err != nil {
184 return nil, err
185 }
186 var dialIP net.IP
187 for _, ip := range ips {
188 addr, ok := netip.AddrFromSlice(ip.IP)
189 if (!ok || isPrivateAddr(addr)) && !allowed {
190 continue
191 }
192 dialIP = ip.IP
193 break
194 }
195 if dialIP == nil {
196 return nil, fmt.Errorf("no allowed resolved address for favicon host %s", host)
197 }
198 var d net.Dialer
199 return d.DialContext(ctx, network, net.JoinHostPort(dialIP.String(), port))
200}
201
202func supportedFetchedContentType(ct string) bool {
203 switch ct {
204 case "image/png", "image/x-icon", "image/vnd.microsoft.icon", "image/jpeg", "image/gif", "image/svg+xml", "image/webp":
205 return true
206 }
207 return false
208}
209
210func bodyMatchesContentType(ct string, body []byte) bool {
211 switch ct {
212 case "image/png":
213 return bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n"))
214 case "image/jpeg":
215 return len(body) >= 3 && body[0] == 0xff && body[1] == 0xd8 && body[2] == 0xff
216 case "image/gif":
217 return bytes.HasPrefix(body, []byte("GIF87a")) || bytes.HasPrefix(body, []byte("GIF89a"))
218 case "image/x-icon", "image/vnd.microsoft.icon":
219 return len(body) >= 4 && body[0] == 0 && body[1] == 0 && (body[2] == 1 || body[2] == 2) && body[3] == 0
220 case "image/webp":
221 return len(body) >= 12 && bytes.HasPrefix(body, []byte("RIFF")) && string(body[8:12]) == "WEBP"
222 case "image/svg+xml":
223 trimmed := bytes.TrimSpace(body)
224 return bytes.HasPrefix(trimmed, []byte("<svg")) || (bytes.HasPrefix(trimmed, []byte("<?xml")) && bytes.Contains(trimmed, []byte("<svg")))
225 default:
226 return false
227 }
228}
229
230func allowedURLForPage(pageURL string, iconURL *url.URL) bool {
231 if !isInternalHost(iconURL.Hostname()) {
232 return true
233 }
234 page, err := url.Parse(pageURL)
235 if err != nil {
236 return false
237 }
238 return isLocalhost(page.Hostname()) && sameOrigin(page, iconURL)
239}
240
241func allowedURLForOrigin(ctx context.Context, target *url.URL) bool {
242 if !isInternalHost(target.Hostname()) {
243 return true
244 }
245 allowed, _ := ctx.Value(allowedLocalOriginKey).(string)
246 targetOrigin := normalizeOriginHostPort(target.Hostname(), target.Port(), target.Scheme)
247 return allowed != "" && strings.EqualFold(allowed, targetOrigin)
248}
249
250func allowedLocalOrigin(pageURL string) string {
251 page, err := url.Parse(pageURL)
252 if err != nil || !isLocalhost(page.Hostname()) {
253 return ""
254 }
255 return normalizeOriginHostPort(page.Hostname(), page.Port(), page.Scheme)
256}
257
258func sameOrigin(a, b *url.URL) bool {
259 return strings.EqualFold(a.Scheme, b.Scheme) &&
260 normalizeOriginHostPort(a.Hostname(), a.Port(), a.Scheme) == normalizeOriginHostPort(b.Hostname(), b.Port(), b.Scheme)
261}
262
263func normalizeOriginHostPort(host, port, scheme string) string {
264 if port == "" {
265 switch strings.ToLower(scheme) {
266 case "http":
267 port = "80"
268 case "https":
269 port = "443"
270 }
271 }
272 if port == "" {
273 return strings.ToLower(host)
274 }
275 return strings.ToLower(net.JoinHostPort(host, port))
276}
277
278func isInternalHost(host string) bool {
279 if isLocalhost(host) {
280 return true
281 }
282 ips, err := net.LookupIP(host)
283 if err != nil {
284 return true
285 }
286 for _, ip := range ips {
287 addr, ok := netip.AddrFromSlice(ip)
288 if !ok || isPrivateAddr(addr) {
289 return true
290 }
291 }
292 return false
293}
294
295func isLocalhost(host string) bool {
296 return strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1"
297}
298
299func isPrivateAddr(addr netip.Addr) bool {
300 return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
301}