A favorite entered as a bare host ("ctl.de") passed @IsUrl() but had no
scheme, so `new URL()` threw inside icon discovery and it silently fell
back to a relative "/favicon.ico" — which 502'd through the icon proxy
and left the widget showing the first-letter placeholder ("C").
- add normalizeUrl() (prepend https:// when no scheme present)
- apply it in discoverFavoriteIconUrl and when storing the favorite url,
so both the link and discovery use the normalized value
- on update, re-run discovery when the icon field is cleared, so editing
a previously-broken favorite repairs its icon
- tests: normalizeUrl cases + end-to-end discovery (apple-touch extraction,
scheme-less fallback stays absolute)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reproducibly confirmed (3/3 vs 3/3 direct comparison inside the API
container) that chatgpt.com's Cloudflare WAF returns 403 for the
"tessera/1.0" User-Agent regardless of Accept header, and 200 for a
real Chrome UA string. Parameterized fetchWithRedirectGuard's
User-Agent (defaulting to the existing "tessera/1.0") and override it
only for fetchIconBytes -- the HTML-discovery path (discoverFavoriteIconUrl)
keeps its original User-Agent unchanged, per the no-regression constraint
on that flow.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A bare "image/*" Accept paired with the tessera/1.0 User-Agent tripped
Cloudflare bot mitigation on some sites -- caught live testing against
chatgpt.com/favicon.ico, which returned 403 with this combo but 200
with a realistic browser-style image Accept list. Isolated via direct
fetch comparison inside the API container before landing the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ownership-scoped (userId, matching update/remove) icon byte proxy.
Loads the row's stored iconUrl server-side and streams it through
IconDiscoveryService.fetchIconBytes -- never accepts a client-supplied
URL, so this can't become an open SSRF proxy.
Not-found/not-owned/no-icon -> 404. Upstream fetch failure (unreachable,
timeout, non-image, SSRF-blocked) -> 502, never a 200 with a placeholder.
Success sets Cache-Control so the browser doesn't refetch every load.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracts the manual-redirect/per-hop-revalidation/timeout loop from
fetchHtml into a shared fetchWithRedirectGuard, exports isPublicHttpUrl,
and adds fetchIconBytes() -- an image-content-type-gated, 1MB-capped
byte fetch reusing the same SSRF guard as the existing HTML discovery
path. discoverFavoriteIconUrl behavior is unchanged.
Prepares the fix for favicon hotlinks breaking on sites that send
Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai) -- Tessera
will proxy the bytes through its own origin instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CR-01: fix SSRF bypass — isPrivateIpv6 now delegates ::ffff:<ipv4> to
isPrivateIpv4, covering 172.16-31.x and 169.254.x ranges
- CR-02: add ParseUUIDPipe to GET /favorites widgetId param + service guard
so missing widgetId returns 400 instead of leaking all user favorites
- WR-01: link-widget — replace raw 'link.error' key with t('link.error') (4 sites)
- WR-02: favorites-widget — fix load-path error to use t('favorites.error')
- WR-03: widget-catalog-modal — move aria-hidden from outer wrapper to backdrop
- WR-04: calculator — remove duplicate M button (MR clone); MC/MR/M+/M−/MS remain
- WR-05: schema — add FavoriteLink→WidgetInstance FK with onDelete:Cascade
- IN-01: create-widget.dto.ts — update comment from four to eight supported types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>