Files
Password-Manager/delphi-backend/Source/PM.Favicon.pas
T
Zaki ad5fb21a18 feat: website favicons + vault health dashboard
Favicons
- PM.Favicon (new): THTTPClient/WinHTTP proxy to icons.duckduckgo.com.
  Native Windows TLS — no OpenSSL DLLs to ship (Indy would fail
  silently without them). 5 s timeout, max 3 redirects, 64 KB cap,
  magic-byte MIME sniffing.
- DB: vault_entries.icon_b64 TEXT (idempotent migration).
- Endpoints: POST /entries/{id}/icon stores a cached data URI without
  forcing a full PUT (which would re-encrypt the password). DELETE
  /entries/icons/all purges the cache.
- Bridge cmd://favicon/fetch?host=X&reqId=Y runs in an anonymous thread
  so the up-to-5 s HTTP GET doesn't block the main thread; result
  shipped back via Bridge.onFaviconResult(reqId, host, dataUri).
- Hostname validated on both sides (JS faviconHost + Delphi
  NormalizeHost) so brand labels like "Gitea" never leak upstream.
- Settings: opt-in "Fetch website icons" toggle (synced), three explicit
  actions (Fetch missing / Re-fetch all / Clear cache) that bypass the
  toggle — manual user actions always work.
- Entry card avatar shows <img> when cached, falls back to initials.
  onerror handler recovers silently from a corrupt data URI.

Vault health
- New sidebar Tools → "Vault health" view. Four category cards:
  Weak (strength < 50), Reused (same plaintext on ≥ 2 entries), Old
  (updated_at > 365d), Pwned (HIBP cache).
- Score 0-100 with colour band (Good/Fair/At risk/Critical).
- One-shot computation cached per session (healthCache), invalidated
  on lockVault, entry save, and the explicit "Recompute" button.
- "Fix" button on each item opens the slideover for the affected
  entry, unmasks the password, focuses it, and pulses the dice button
  — full context preserved, user decides how to fix.
- Click handler stopPropagation prevents the document-level
  "click outside slideover" listener from closing the panel that
  we just opened in the same click event.

Fixes
- openSlideover typo (lowercase O) → openSlideOver across all call
  sites. Was silently breaking the Authenticator card click and the
  Vault health Fix button.
- W1050 WideChar warning in PM.Favicon — replaced set-membership
  with explicit Ord-style range comparisons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 00:05:54 +01:00

157 lines
5.4 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit PM.Favicon;
{
Favicon proxy — fetches a website's icon and returns a base64 data URI
ready to drop into an <img src="...">.
Source: DuckDuckGo's icons service (icons.duckduckgo.com/ip3/<host>.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 : "<svg" or "<?xml" (text-prefixed)
if (ABytes[0] = Ord('<')) then Exit('image/svg+xml');
// ICO : 00 00 01 00
if (ABytes[0] = $00) and (ABytes[1] = $00) and
(ABytes[2] = $01) and (ABytes[3] = $00) then
Exit('image/x-icon');
end;
function FetchFaviconDataUri(const AHost: string): string;
var
LHost, LUrl, LMime, LBase64: string;
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TMemoryStream;
LBytes: TBytes;
begin
Result := '';
LHost := NormalizeHost(AHost);
if LHost = '' then Exit;
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
// zero extra DLLs to ship next to the exe (unlike Indy + OpenSSL which
// fails silently when libcrypto/libssl are missing).
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(LUrl, LStream);
except
// Any DNS / connect / TLS failure → silent ''.
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(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 <img src="..."> 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.