unit PM.Favicon; { Favicon proxy — fetches a website's icon and returns a base64 data URI ready to drop into an . Source: DuckDuckGo's icons service (icons.duckduckgo.com/ip3/.ico) - No tracking pixels / analytics on the icon endpoints - Returns a 32×32 PNG (or ICO) with the proper MIME type - Centralised: only DDG sees the list of domains the user looks up, vs hitting each site's /favicon.ico directly (which would leak the full vault contents to every site listed) - Falls back to a generic globe glyph for unknown sites HTTPS only; 5s timeout; cap response at 64 KB; no redirects beyond 3. Threat model: this is the ONLY outbound network call from Delphi (HIBP is done client-side). The user explicitly opts in via Settings. Failure modes (DNS, TLS, 4xx, oversize) all return '' — caller falls back to the first-letter avatar. } interface // Fetches an icon for AHost (bare hostname, no scheme). Returns a // "data:image/...;base64,..." string on success, or '' on any failure. function FetchFaviconDataUri(const AHost: string): string; implementation uses System.SysUtils, System.Classes, System.NetEncoding, System.Net.HttpClient, System.Net.URLClient; const ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico'; MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit) HTTP_TIMEOUT_MS = 5000; function NormalizeHost(const ARaw: string): string; var S: string; SlashPos, ColonPos, I: Integer; Ch: Char; begin // Accept anything user-typed: "https://www.github.com/login", "github.com", // "GitHub.com:8443". Return lowercase bare hostname, or '' if the input // doesn't look like a real domain — defense in depth alongside the JS // faviconHost() validation (so a future bridge caller can't leak a // brand label like "Gitea" upstream). Result := ''; S := Trim(ARaw).ToLower; if S.StartsWith('https://') then S := Copy(S, 9, MaxInt) else if S.StartsWith('http://') then S := Copy(S, 8, MaxInt); if S.StartsWith('www.') then S := Copy(S, 5, MaxInt); SlashPos := Pos('/', S); if SlashPos > 0 then S := Copy(S, 1, SlashPos - 1); ColonPos := Pos(':', S); if ColonPos > 0 then S := Copy(S, 1, ColonPos - 1); if (S = '') or (Length(S) > 253) then Exit; // Must contain a dot, no leading/trailing dot, no consecutive dots, // only [a-z0-9.-] characters. if Pos('.', S) < 2 then Exit; if S.StartsWith('.') or S.EndsWith('.') or S.Contains('..') then Exit; for I := 1 to Length(S) do begin Ch := S[I]; if not (((Ch >= 'a') and (Ch <= 'z')) or ((Ch >= '0') and (Ch <= '9')) or (Ch = '.') or (Ch = '-')) then Exit; end; Result := S; end; function GuessMimeFromBytes(const ABytes: TBytes): string; begin // Lightweight magic-byte sniffing. Saves a Content-Type round-trip parse. Result := 'image/x-icon'; // safe default for an .ico fetch if Length(ABytes) < 8 then Exit; // PNG : 89 50 4E 47 0D 0A 1A 0A if (ABytes[0] = $89) and (ABytes[1] = $50) and (ABytes[2] = $4E) and (ABytes[3] = $47) then Exit('image/png'); // GIF : "GIF8" if (ABytes[0] = Ord('G')) and (ABytes[1] = Ord('I')) and (ABytes[2] = Ord('F')) and (ABytes[3] = Ord('8')) then Exit('image/gif'); // JPEG : FF D8 FF if (ABytes[0] = $FF) and (ABytes[1] = $D8) and (ABytes[2] = $FF) then Exit('image/jpeg'); // SVG : " 200) then Exit; if LStream.Size <= 0 then Exit; if LStream.Size > MAX_ICON_BYTES then Exit; LStream.Position := 0; SetLength(LBytes, LStream.Size); LStream.ReadBuffer(LBytes[0], LStream.Size); LMime := GuessMimeFromBytes(LBytes); LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); // Strip CR/LF that the encoder inserts every 76 chars — invalid inside // an attribute and bloats the cached blob. LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]); LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]); Result := 'data:' + LMime + ';base64,' + LBase64; finally LStream.Free; LHttp.Free; end; end; end.