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 type TFaviconLog = reference to procedure(const ALine: string); // Fetches an icon for AHost (bare hostname, no scheme). Returns a // "data:image/...;base64,..." string on success, or '' on any failure. // ALog (optional): called for each fallback step so the host can trace // exactly which URL hit / missed. function FetchFaviconDataUri(const AHost: string; ALog: TFaviconLog = nil): 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 = 262144; // 256 KB cap (DDG sometimes serves full-res // assets; matches handler + JS upload limits) HTTP_TIMEOUT_MS = 5000; // DDG returns a generic placeholder for unknown domains. Bigger threshold // than 100 to avoid treating its blank globe glyph as a real icon. MIN_REAL_ICON_BYTES = 300; // Privacy stance: DDG-only fetches. We don't fall back to the site's // own /favicon.ico because that would leak DNS to every domain stored // in the vault. For sites DDG doesn't index, the user can upload a // custom icon via the slideover (soIconField). 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 : " '' then begin LUrl := Format(ICON_URL_TEMPLATE, [LSld]); if FetchOneIcon(LUrl, LBytes) then begin if Length(LBytes) >= MIN_REAL_ICON_BYTES then begin LOk := True; Trace(Format('OK step1 DDG sld: %s (%d bytes)', [LUrl, Length(LBytes)])); end else Trace(Format('skip step1 DDG sld: %s only %d bytes', [LUrl, Length(LBytes)])); end else Trace('fail step1 DDG sld: ' + LUrl); end; // 2) DDG full host as fallback (covers brands whose subdomain has its own // distinct icon, OR plain hosts like github.com that have no SLD step). if not LOk then begin var LTry: TBytes; LUrl := Format(ICON_URL_TEMPLATE, [LHost]); if FetchOneIcon(LUrl, LTry) then begin if Length(LTry) >= MIN_REAL_ICON_BYTES then begin LBytes := LTry; LOk := True; Trace(Format('OK step2 DDG host: %s (%d bytes)', [LUrl, Length(LTry)])); end else Trace(Format('skip step2 DDG host: %s only %d bytes', [LUrl, Length(LTry)])); end else Trace('fail step2 DDG host: ' + LUrl); end; if (not LOk) or (Length(LBytes) = 0) then begin Trace('DDG has no icon for ' + LHost + ' — user can upload a custom one'); Exit; end; LMime := GuessMimeFromBytes(LBytes); LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]); LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]); Result := 'data:' + LMime + ';base64,' + LBase64; end; // Low-level HTTP GET. Returns False on any failure (DNS, TLS, non-200, // oversize). On success ABytes contains the raw image bytes. // THTTPClient wraps WinHTTP on Windows → native TLS, system cert store, // zero extra DLLs to ship next to the exe. function FetchOneIcon(const AUrl: string; out ABytes: TBytes): Boolean; var LHttp: THTTPClient; LResp: IHTTPResponse; LStream: TMemoryStream; begin Result := False; SetLength(ABytes, 0); LHttp := THTTPClient.Create; LStream := TMemoryStream.Create; try LHttp.ConnectionTimeout := HTTP_TIMEOUT_MS; LHttp.ResponseTimeout := HTTP_TIMEOUT_MS; LHttp.HandleRedirects := True; LHttp.MaxRedirects := 3; LHttp.UserAgent := 'PMServer/1.0 (favicon-fetch)'; LHttp.CustHeaders.Add('Accept', 'image/png,image/x-icon,image/*;q=0.8,*/*;q=0.1'); try LResp := LHttp.Get(AUrl, LStream); except Exit; end; if (LResp = nil) or (LResp.StatusCode <> 200) then Exit; if LStream.Size <= 0 then Exit; if LStream.Size > MAX_ICON_BYTES then Exit; LStream.Position := 0; SetLength(ABytes, LStream.Size); LStream.ReadBuffer(ABytes[0], LStream.Size); Result := True; finally LStream.Free; LHttp.Free; end; end; end.