From 6c5ba2e63f2571a4cfae94ea7e6d7b9711c60a86 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 19:51:28 -0700 Subject: [PATCH 1/4] 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. --- apps/sim/app/api/favicon/route.ts | 31 ++++ .../[organizationId]/links/sim-link-icon.tsx | 22 +-- .../agent-group/browser-agent-icon.test.tsx | 6 +- .../agent-group/browser-agent-icon.tsx | 16 +- .../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 +++ .../server/routes/internal-binary-route.ts | 15 +- .../api/server/routes/internal-json-route.ts | 2 +- apps/sim/lib/core/utils/favicon.ts | 9 -- apps/sim/lib/favicon/application.ts | 19 +++ apps/sim/lib/favicon/hosts.test.ts | 69 ++++++++ apps/sim/lib/favicon/hosts.ts | 24 +++ apps/sim/lib/favicon/probe.test.ts | 44 +++++ apps/sim/lib/favicon/probe.ts | 30 ++++ apps/sim/lib/favicon/resolve.server.ts | 151 ++++++++++++++++++ apps/sim/lib/favicon/url.ts | 6 + 20 files changed, 479 insertions(+), 90 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..836cbaf8d7c --- /dev/null +++ b/apps/sim/app/api/favicon/route.ts @@ -0,0 +1,31 @@ +import { getFaviconContract } from '@/lib/api/contracts/favicon' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +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, + present: ({ bytes, contentType }) => ({ + body: new Uint8Array(bytes), + contentType, + contentLength: bytes.length, + headers: { + 'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800', + 'X-Content-Type-Options': 'nosniff', + }, + }), +}) 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 (