From 17389db075c2900995d611e496361de8d8106c81 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 30 Oct 2026 20:41:18 -0700 Subject: [PATCH 1/6] feat(favicons): one server route for every site icon, with parent-domain fallback (#9494) * feat(favicons): resolve every site icon through one server route with parent-domain fallback Icons were requested straight from Google in the browser, which shows Google's placeholder globe for any host it can't see (a 404 with an image body that an treats as success), so private tenants and internal hosts rendered a generic placeholder. GET /api/favicon asks Google and DuckDuckGo server-side, where the 404 is visible, walks up to the parent domain so a tenant shows its platform's icon, never sends internal hosts or the destination itself anything, and caches results in Redis. Every surface now uses the shared or faviconSrc, and binary internal routes accept a user rate limit. * fix(favicons): 204 for no icon, only 404 is a provider miss, reject empty labels and non-DNS hostnames A miss is an ordinary answer, so it returns 204 (which an img still reports as failed) instead of an error status; binary internal routes may now declare several success statuses and present a no-content response, sharing the JSON builder's status validation. Providers are asked one at a time so a second only learns a host the first had no icon for, a non-200 body is never read, and anything but 404/410 counts as unavailable rather than a cached miss. * fix(favicons): keep the presenter's 204 literal and read only a 200 body before one classification * fix(favicons): annotate the presenter so each branch is checked against its own descriptor --- apps/sim/app/api/favicon/route.ts | 44 ++++++ .../[organizationId]/links/sim-link-icon.tsx | 22 +-- .../agent-group/browser-agent-icon.test.tsx | 6 +- .../agent-group/browser-agent-icon.tsx | 16 +- .../source-chip/source-icon.test.tsx | 2 +- .../components/source-chip/source-icon.tsx | 23 +-- .../browser-session/browser-session.tsx | 29 ++-- .../resource-registry/browser-tab-icon.tsx | 4 +- .../table-grid/cells/cell-render.tsx | 16 +- apps/sim/components/favicon.tsx | 34 ++++ apps/sim/lib/api/contracts/favicon.ts | 19 +++ apps/sim/lib/api/server/routes/definition.ts | 45 +++--- .../server/routes/internal-binary-route.ts | 33 ++-- .../api/server/routes/internal-json-route.ts | 2 +- apps/sim/lib/api/server/routes/types.ts | 6 + apps/sim/lib/core/utils/favicon.ts | 9 -- apps/sim/lib/favicon/application.ts | 14 ++ apps/sim/lib/favicon/hosts.test.ts | 90 +++++++++++ apps/sim/lib/favicon/hosts.ts | 29 ++++ apps/sim/lib/favicon/probe.test.ts | 48 ++++++ apps/sim/lib/favicon/probe.ts | 31 ++++ apps/sim/lib/favicon/resolve.server.ts | 146 ++++++++++++++++++ apps/sim/lib/favicon/url.ts | 6 + 23 files changed, 557 insertions(+), 117 deletions(-) create mode 100644 apps/sim/app/api/favicon/route.ts create mode 100644 apps/sim/components/favicon.tsx create mode 100644 apps/sim/lib/api/contracts/favicon.ts delete mode 100644 apps/sim/lib/core/utils/favicon.ts create mode 100644 apps/sim/lib/favicon/application.ts create mode 100644 apps/sim/lib/favicon/hosts.test.ts create mode 100644 apps/sim/lib/favicon/hosts.ts create mode 100644 apps/sim/lib/favicon/probe.test.ts create mode 100644 apps/sim/lib/favicon/probe.ts create mode 100644 apps/sim/lib/favicon/resolve.server.ts create mode 100644 apps/sim/lib/favicon/url.ts diff --git a/apps/sim/app/api/favicon/route.ts b/apps/sim/app/api/favicon/route.ts new file mode 100644 index 00000000000..1a52f8417b1 --- /dev/null +++ b/apps/sim/app/api/favicon/route.ts @@ -0,0 +1,44 @@ +import { getFaviconContract } from '@/lib/api/contracts/favicon' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import type { + BinaryResponseDescriptor, + NoContentResponseDescriptor, +} from '@/lib/api/server/routes/types' +import { getFavicon } from '@/lib/favicon/application' + +export const GET = defineInternalBinaryRoute({ + contract: getFaviconContract, + auth: internalSessionAuth, + operation: getFavicon.operation, + rateLimit: internalRateLimits.user({ + bucketName: 'favicon', + /** A page can render dozens of icons at once; the browser caches each one after its first load. */ + config: { maxTokens: 300, refillRate: 300, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ host: query.host }), + useCase: getFavicon, + /** No icon is an ordinary answer, not an error: 204, which an `` still reports as failed. */ + present: (favicon): BinaryResponseDescriptor | NoContentResponseDescriptor => + favicon.kind === 'hit' + ? { + body: new Uint8Array(favicon.bytes), + contentType: favicon.contentType, + contentLength: favicon.bytes.length, + headers: { + 'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800', + 'X-Content-Type-Options': 'nosniff', + }, + } + : { + status: 204, + headers: { + 'Cache-Control': favicon.kind === 'miss' ? 'private, max-age=3600' : 'no-store', + }, + }, +}) diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx index 4849b22ed33..f28f247f9ce 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx @@ -1,8 +1,5 @@ -'use client' - -import { useState } from 'react' import { Globe } from '@sim/emcn/icons' -import { faviconUrl } from '@/lib/core/utils/favicon' +import { Favicon } from '@/components/favicon' import { simLinkOrigin } from '@/lib/sim-links/validation' interface SimLinkIconProps { @@ -10,21 +7,8 @@ interface SimLinkIconProps { url: string } -/** The destination site's favicon, or a globe when the site has none. */ +/** The destination site's favicon, or a globe when neither it nor a parent domain has one. */ export function SimLinkIcon({ url }: SimLinkIconProps) { - const [failedHostname, setFailedHostname] = useState(null) const origin = simLinkOrigin(url) - const hostname = origin ? new URL(origin).hostname : null - if (!hostname || failedHostname === hostname) return - return ( - setFailedHostname(hostname)} - /> - ) + return } /> } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx index 1e34090c1f8..b3455260f4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx @@ -47,7 +47,7 @@ function tool(overrides: Partial = {}): AgentGroupItem { } describe('getBrowserAgentFaviconUrl', () => { - it('keeps the observed origin and port without disclosing page details', () => { + it('keeps only the observed hostname, never credentials, port, path, or query', () => { expect( getBrowserAgentFaviconUrl([ tool({ @@ -59,7 +59,7 @@ describe('getBrowserAgentFaviconUrl', () => { }, }), ]) - ).toBe('https://example.com:8443/favicon.ico') + ).toBe('/api/favicon?host=example.com') }) it('ignores URLs from other tools and nested agent runs', () => { @@ -87,7 +87,7 @@ describe('getBrowserAgentFaviconUrl', () => { }, }, ]) - ).toBe('https://example.com/favicon.ico') + ).toBe('/api/favicon?host=example.com') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx index 5bf1c53ac4d..2d0b925e8c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.tsx @@ -6,20 +6,17 @@ import { cn } from '@sim/emcn' import { Globe } from '@sim/emcn/icons' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' +import { faviconSrc } from '@/lib/favicon/url' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { useBrowserSessionStore } from '@/stores/browser-session/store' +/** Only the page's hostname leaves the browser, and only to Sim's favicon route. */ function pageFaviconUrl(url: string): string | null { - try { - const page = new URL(url) - /** External images must use HTTPS under the app's content security policy. */ - if (page.protocol !== 'https:') return null - return `${page.origin}/favicon.ico` - } catch { - return null - } + if (!URL.canParse(url)) return null + const page = new URL(url) + return page.protocol === 'https:' || page.protocol === 'http:' ? faviconSrc(page.hostname) : null } /** Uses this run's page observations so historical icons never follow another run's live tab. */ @@ -120,11 +117,10 @@ function BrowserAgentFavicon({ url, canLoad }: BrowserAgentFaviconProps) { return (