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 (
- {/** History alone must not contact a site; keep an already loaded image after the tab closes. */}
+ {/* History alone never looks up a site's icon; keep an already loaded image after the tab closes. */}
{url && status !== 'failed' && (canLoad || status === 'loaded') && (
{
container.innerHTML = renderToStaticMarkup()
const image = container.querySelector('img')
expect(image).not.toBeNull()
- expect(new URL(image!.src).searchParams.get('domain')).toBe(new URL(url).hostname)
+ expect(new URL(image!.src).searchParams.get('host')).toBe(new URL(url).hostname)
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx
index d230b9f41c3..2b9d509fc4d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx
@@ -1,11 +1,10 @@
'use client'
-import { useState } from 'react'
import { cn } from '@sim/emcn'
import { FileText } from '@sim/emcn/icons'
import { stripVersionSuffix } from '@sim/utils/string'
+import { Favicon } from '@/components/favicon'
import { GoogleDocsIcon, GoogleSheetsIcon, GoogleSlidesIcon } from '@/components/icons'
-import { faviconUrl } from '@/lib/core/utils/favicon'
import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
import { externalLinkHostname } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
@@ -45,7 +44,6 @@ interface SourceIconProps {
/** Shared document mark, connector mark, favicon and fallback across all source presentations. */
export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
- const [failedHostname, setFailedHostname] = useState(null)
const hostname = externalLinkHostname(source.url)
const ConnectorIcon = source.connectorType
? BRAND_ICON_BY_BASE_TYPE.get(stripVersionSuffix(source.connectorType))
@@ -62,16 +60,11 @@ export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
const Icon = DocumentIcon ?? ConnectorIcon ?? HostIcon
const className = cn('shrink-0', size === 'inline' ? 'size-[12px]' : 'size-[14px]')
if (Icon) return
- if (hostname && failedHostname !== hostname) {
- return (
-
setFailedHostname(hostname)}
- />
- )
- }
- return
+ return (
+ }
+ />
+ )
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
index 45bb45e1752..786c75934aa 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
@@ -34,6 +34,7 @@ import { useTheme } from 'next-themes'
import { createPortal } from 'react-dom'
import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog'
import { EmptyState } from '@/components/empty-state/empty-state'
+import { Favicon } from '@/components/favicon'
import {
onBrowserOmniboxFocusRequest,
takeBrowserOmniboxFocusRequest,
@@ -58,7 +59,6 @@ import {
showBrowserToolbarMenu,
supportsAtomicBrowserPanelOcclusion,
} from '@/lib/browser-agent/transport'
-import { faviconUrl } from '@/lib/core/utils/favicon'
import { getDesktopBridge } from '@/lib/desktop'
import {
loadDesktopBrowserAppearanceTheme,
@@ -104,20 +104,27 @@ const OMNIBOX_DRAG_THRESHOLD_PX = 4
const suggestionRowId = (index: number) => `${SUGGESTIONS_LIST_ID}-${index}`
+/** A browser-imported icon stays local; otherwise the site's favicon. */
function BrowserSuggestionIcon({ suggestion }: { suggestion: UrlSuggestion }) {
- const [failed, setFailed] = useState(false)
- const source = suggestion.icon || faviconUrl(suggestion.hostname, 32)
-
- if (failed) {
- return
+ const [failedIcon, setFailedIcon] = useState(null)
+ const fallback =
+ if (suggestion.icon) {
+ if (failedIcon === suggestion.icon) return fallback
+ const icon = suggestion.icon
+ return (
+
setFailedIcon(icon)}
+ />
+ )
}
-
return (
-
setFailed(true)}
+ fallback={fallback}
/>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx
index cb0d6042c20..6f09fd1a260 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx
@@ -5,7 +5,7 @@ import { cn } from '@sim/emcn'
import { CircleAlert, Globe, Loader } from '@sim/emcn/icons'
import { ThinkingLoader } from '@/components/ui'
import { browserTabHostname, shouldShowBrowserTabSpinner } from '@/lib/browser-agent/tab-label'
-import { faviconUrl } from '@/lib/core/utils/favicon'
+import { faviconSrc } from '@/lib/favicon/url'
import { useBrowserSessionStore } from '@/stores/browser-session/store'
interface BrowserTabIconProps {
@@ -61,7 +61,7 @@ export function BrowserTabIcon({ tabId, scopeId, className }: BrowserTabIconProp
{hostname && !faviconFailed && (
-
{
- e.currentTarget.style.display = 'none'
- }}
+ }
/>
(null)
+ if (!host || failedHost === host) return fallback
+ return (
+
setFailedHost(host)}
+ />
+ )
+}
diff --git a/apps/sim/lib/api/contracts/favicon.ts b/apps/sim/lib/api/contracts/favicon.ts
new file mode 100644
index 00000000000..67a53d53c9d
--- /dev/null
+++ b/apps/sim/lib/api/contracts/favicon.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod'
+import { defineRouteContract } from '@/lib/api/contracts/types'
+
+/**
+ * A site's icon by hostname: image bytes, or 204 when none can be served — neither the host nor
+ * a parent has one, the host is internal, or the providers are unavailable.
+ */
+export const getFaviconContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/favicon',
+ query: z.object({
+ host: z
+ .string()
+ .trim()
+ .min(1, 'host is required')
+ .max(253, 'host must be 253 characters or less'),
+ }),
+ response: { mode: 'binary', status: [200, 204] },
+})
diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts
index 2d3642e85d2..f870dbd3280 100644
--- a/apps/sim/lib/api/server/routes/definition.ts
+++ b/apps/sim/lib/api/server/routes/definition.ts
@@ -20,6 +20,27 @@ export function requireJsonRouteDefinition(
)
}
+ return declaredSuccessStatuses(contract)
+}
+
+export function requireBinaryRouteDefinition(
+ contract: AnyApiRouteContract,
+ declaredOperation: ApplicationOperation,
+ useCaseOperation: ApplicationOperation
+): JsonRouteDefinitionMetadata {
+ if (contract.response.mode !== 'binary') {
+ throw new Error(`${contract.method} ${contract.path} requires a binary response contract`)
+ }
+ if (declaredOperation.id !== useCaseOperation.id) {
+ throw new Error(
+ `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}`
+ )
+ }
+ return declaredSuccessStatuses(contract)
+}
+
+/** The contract's success statuses, 200 by default; the first is a route's default status. */
+function declaredSuccessStatuses(contract: AnyApiRouteContract): JsonRouteDefinitionMetadata {
const configuredStatus = contract.response.status
const successStatuses =
configuredStatus === undefined
@@ -41,30 +62,6 @@ export function requireJsonRouteDefinition(
return { successStatus: successStatuses[0], successStatuses }
}
-export function requireBinaryRouteDefinition(
- contract: AnyApiRouteContract,
- declaredOperation: ApplicationOperation,
- useCaseOperation: ApplicationOperation
-): JsonRouteDefinitionMetadata {
- if (contract.response.mode !== 'binary') {
- throw new Error(`${contract.method} ${contract.path} requires a binary response contract`)
- }
- if (declaredOperation.id !== useCaseOperation.id) {
- throw new Error(
- `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}`
- )
- }
- const configuredStatus = contract.response.status
- if (configuredStatus !== undefined && typeof configuredStatus !== 'number') {
- throw new Error(`${contract.method} ${contract.path} must declare one success status`)
- }
- const successStatus = configuredStatus ?? 200
- if (successStatus < 200 || successStatus >= 300) {
- throw new Error(`${contract.method} ${contract.path} has a non-success response status`)
- }
- return { successStatus, successStatuses: [successStatus] }
-}
-
/**
* Whether an incoming request's method is the one its contract declares.
*
diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts
index ed5729c45f1..1fa4a3ab4a7 100644
--- a/apps/sim/lib/api/server/routes/internal-binary-route.ts
+++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts
@@ -1,6 +1,5 @@
-import { describePrincipalAuth, type Principal, type SessionPrincipal } from '@sim/auth/principal'
+import { describePrincipalAuth, type SessionPrincipal } from '@sim/auth/principal'
import { setRequestAuth } from '@sim/logger'
-import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
methodMatchesContract,
@@ -8,6 +7,7 @@ import {
} from '@/lib/api/server/routes/definition'
import {
type InternalErrorPolicy,
+ type InternalRateLimitPolicy,
InternalUnauthenticatedError,
internalErrorResponse,
type internalSessionAuth,
@@ -19,18 +19,13 @@ import type {
JsonErrorResponseDescriptor,
JsonNextRouteHandler,
JsonRouteContext,
+ NoContentResponseDescriptor,
} from '@/lib/api/server/routes/types'
import type { ParsedRequest, ParseRequestOptions } from '@/lib/api/server/validation'
import { parseRequest } from '@/lib/api/server/validation'
import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-interface InternalBinaryRateLimitPolicy {
- readonly kind: 'none'
- readonly reason: string
- enforce(request: NextRequest, principal: Principal): Promise
-}
-
interface InternalBinaryRouteDefinition<
C extends BinaryApiRouteContract,
O extends ApplicationOperation,
@@ -41,7 +36,12 @@ interface InternalBinaryRouteDefinition<
operation: O
mapInput(input: ParsedRequest): I
useCase: OperationUseCase, I, R>
- present(result: R): BinaryResponseDescriptor | Promise
+ present(
+ result: R
+ ):
+ | BinaryResponseDescriptor
+ | NoContentResponseDescriptor
+ | Promise
}
interface InternalBinaryRouteOptions<
@@ -51,7 +51,7 @@ interface InternalBinaryRouteOptions<
R,
> extends InternalBinaryRouteDefinition {
auth: typeof internalSessionAuth
- rateLimit: InternalBinaryRateLimitPolicy
+ rateLimit: InternalRateLimitPolicy
errorPolicy: InternalErrorPolicy
parseOptions?: Pick
headSafe?: boolean
@@ -70,7 +70,7 @@ export function defineInternalBinaryRoute<
I,
R,
>(options: InternalBinaryRouteOptions): JsonNextRouteHandler {
- const { successStatus } = requireBinaryRouteDefinition(
+ const { successStatus, successStatuses } = requireBinaryRouteDefinition(
options.contract,
options.operation,
options.useCase.operation
@@ -99,7 +99,8 @@ export function defineInternalBinaryRoute<
}
setRequestAuth(describePrincipalAuth(principal))
- await options.rateLimit.enforce(request, principal)
+ const rateLimitResponse = await options.rateLimit.enforce(request, principal)
+ if (rateLimitResponse) return responseWithRequestId(rateLimitResponse)
const parsed = await parseRequest(
options.contract,
request,
@@ -121,6 +122,14 @@ export function defineInternalBinaryRoute<
const result = await options.useCase.execute({ principal, input, request })
const descriptor = await options.present(result)
await options.onSuccess?.({ principal, input, result })
+ if ('status' in descriptor) {
+ if (!successStatuses.includes(descriptor.status)) {
+ throw new Error(
+ `Internal binary route produced undeclared success status ${descriptor.status}; expected ${successStatuses.join(', ')}`
+ )
+ }
+ return new NextResponse(null, { status: descriptor.status, headers: descriptor.headers })
+ }
const headers = new Headers(descriptor.headers)
headers.set('Content-Type', descriptor.contentType)
if (descriptor.contentDisposition) {
diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts
index eb60596d658..4e57e7d7df2 100644
--- a/apps/sim/lib/api/server/routes/internal-json-route.ts
+++ b/apps/sim/lib/api/server/routes/internal-json-route.ts
@@ -119,7 +119,7 @@ interface InternalUserRateLimitPolicy {
enforce(request: NextRequest, principal: Principal): Promise
}
-type InternalRateLimitPolicy = InternalNoRateLimitPolicy | InternalUserRateLimitPolicy
+export type InternalRateLimitPolicy = InternalNoRateLimitPolicy | InternalUserRateLimitPolicy
export const internalRateLimits = {
none({ reason }: { reason: string }): InternalNoRateLimitPolicy {
diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts
index 90b9522dddd..7aa80ca3099 100644
--- a/apps/sim/lib/api/server/routes/types.ts
+++ b/apps/sim/lib/api/server/routes/types.ts
@@ -30,6 +30,12 @@ export interface BinaryResponseDescriptor {
headers?: HeadersInit
}
+/** An empty success from a binary route whose contract declares 204. */
+export interface NoContentResponseDescriptor {
+ status: 204
+ headers?: HeadersInit
+}
+
export interface JsonErrorResponseDescriptor {
body: unknown
status: number
diff --git a/apps/sim/lib/core/utils/favicon.ts b/apps/sim/lib/core/utils/favicon.ts
deleted file mode 100644
index cb4aaff7910..00000000000
--- a/apps/sim/lib/core/utils/favicon.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * URL for a domain's favicon via Google's favicon service.
- *
- * @param domain - Bare hostname (e.g. `x.com`), not a full URL
- * @param size - Requested pixel size; request 2x the display size for retina
- */
-export function faviconUrl(domain: string, size: number): string {
- return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${size}`
-}
diff --git a/apps/sim/lib/favicon/application.ts b/apps/sim/lib/favicon/application.ts
new file mode 100644
index 00000000000..c1934af7424
--- /dev/null
+++ b/apps/sim/lib/favicon/application.ts
@@ -0,0 +1,14 @@
+import { defineOperation } from '@/lib/core/application'
+import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case'
+import { resolveFavicon } from '@/lib/favicon/resolve.server'
+
+/** A site's icon for the signed-in user, or why there is none to serve. */
+export const getFavicon = defineAuthorizedCredentialUserUseCase({
+ // permission-group-exempt: A public site's icon reveals nothing the caller's permissions govern.
+ operation: defineOperation({
+ id: 'favicons.get',
+ principalKinds: ['session'],
+ capability: 'none',
+ }),
+ execute: ({ input }: { input: { host: string } }) => resolveFavicon(input.host),
+})
diff --git a/apps/sim/lib/favicon/hosts.test.ts b/apps/sim/lib/favicon/hosts.test.ts
new file mode 100644
index 00000000000..adf37453d92
--- /dev/null
+++ b/apps/sim/lib/favicon/hosts.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+import { faviconHostCandidates } from '@/lib/favicon/hosts'
+
+describe('faviconHostCandidates', () => {
+ it.each([
+ 'localhost',
+ 'intranet',
+ 'wiki.local',
+ 'build.internal',
+ 'printer.lan',
+ 'jira.corp',
+ 'docs.example',
+ 'nas.home.arpa',
+ '10.0.0.7',
+ '[::1]',
+ '192.168.1.20:8080',
+ ])('never offers internal host %s to a provider', (host) => {
+ expect(faviconHostCandidates(host)).toEqual([])
+ })
+
+ it('walks a private tenant up to its platform', () => {
+ expect(faviconHostCandidates('zq7tenant.atlassian.net')).toEqual([
+ 'zq7tenant.atlassian.net',
+ 'atlassian.net',
+ ])
+ })
+
+ it('treats a private public suffix as the platform, never the bare TLD', () => {
+ expect(faviconHostCandidates('docs.zq7tenant.gitlab.io')).toEqual([
+ 'docs.zq7tenant.gitlab.io',
+ 'zq7tenant.gitlab.io',
+ 'gitlab.io',
+ ])
+ })
+
+ it('stops at the registrable domain under a multi-label public suffix', () => {
+ expect(faviconHostCandidates('intranet.example.co.uk')).toEqual([
+ 'intranet.example.co.uk',
+ 'example.co.uk',
+ ])
+ })
+
+ it('tries at most three parents', () => {
+ expect(faviconHostCandidates('a.b.c.d.e.example.com')).toEqual([
+ 'a.b.c.d.e.example.com',
+ 'b.c.d.e.example.com',
+ 'c.d.e.example.com',
+ 'd.e.example.com',
+ ])
+ })
+
+ it('normalizes case, a trailing dot, and a port', () => {
+ expect(faviconHostCandidates('Docs.Example.COM.:443')).toEqual([
+ 'docs.example.com',
+ 'example.com',
+ ])
+ })
+
+ it('looks up an internationalized host by its ASCII form', () => {
+ expect(faviconHostCandidates('bücher.de')).toEqual(['xn--bcher-kva.de'])
+ })
+
+ it.each(['x.com&size=1.com', 'a&b=c.com', 'a%26b.com', "it's.com", 'a(b).com', 'a+b.com'])(
+ 'offers nothing for %j, whose punctuation would reshape a provider URL',
+ (host) => {
+ expect(faviconHostCandidates(host)).toEqual([])
+ }
+ )
+
+ it.each(['example.com..', '.example.com', 'docs..example.com'])(
+ 'offers nothing for %j, which has an empty label',
+ (host) => {
+ expect(faviconHostCandidates(host)).toEqual([])
+ }
+ )
+
+ it('keeps an underscore, which some real hosts use', () => {
+ expect(faviconHostCandidates('my_app.example.com')).toEqual([
+ 'my_app.example.com',
+ 'example.com',
+ ])
+ })
+
+ it.each(['', 'https://example.com/path', 'exa mple.com', 'user@example.com', 'com'])(
+ 'offers nothing for %j',
+ (host) => {
+ expect(faviconHostCandidates(host)).toEqual([])
+ }
+ )
+})
diff --git a/apps/sim/lib/favicon/hosts.ts b/apps/sim/lib/favicon/hosts.ts
new file mode 100644
index 00000000000..5615f58a256
--- /dev/null
+++ b/apps/sim/lib/favicon/hosts.ts
@@ -0,0 +1,29 @@
+import { parse } from 'tldts'
+
+const MAX_PARENT_HOSTS = 3
+const HOST_SHAPE = /^[^\s/?#@\\]+$/
+
+/**
+ * Hosts to ask a favicon provider about, most specific first. A private tenant such as
+ * `acme.atlassian.net` is invisible to providers, but its platform is not, so each parent
+ * domain follows — at most three, stopping before the ICANN public suffix (`gitlab.io` is a valid last step;
+ * `io` and `co.uk` are not). Internal hosts — IP literals, single labels, and names outside
+ * the public DNS — yield nothing, so they never leave the server, and neither does a name with
+ * characters DNS can't hold, which could otherwise reshape a provider's URL.
+ */
+export function faviconHostCandidates(input: string): string[] {
+ const url = HOST_SHAPE.test(input) ? URL.parse(`https://${input}`) : null
+ if (!url) return []
+ const hostname = url.hostname.replace(/\.$/, '')
+ if (hostname.split('.').some((label) => !label)) return []
+ const { isIp, isIcann, publicSuffix } = parse(hostname, {
+ allowPrivateDomains: false,
+ validateHostname: true,
+ })
+ if (isIp || !isIcann || !publicSuffix || /(^|\.)arpa$/.test(publicSuffix)) return []
+ const labels = hostname.split('.')
+ const candidateCount = labels.length - publicSuffix.split('.').length
+ return labels
+ .slice(0, Math.min(candidateCount, MAX_PARENT_HOSTS + 1))
+ .map((_, index) => labels.slice(index).join('.'))
+}
diff --git a/apps/sim/lib/favicon/probe.test.ts b/apps/sim/lib/favicon/probe.test.ts
new file mode 100644
index 00000000000..393a8a97772
--- /dev/null
+++ b/apps/sim/lib/favicon/probe.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { classifyProviderResponse, MAX_ICON_BYTES } from '@/lib/favicon/probe'
+
+const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])
+const ICO = Uint8Array.from([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10])
+const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10])
+const GIF = new TextEncoder().encode('GIF89a......')
+const WEBP = new TextEncoder().encode('RIFF\u0000\u0000\u0000\u0000WEBPVP8 ')
+const HTML = new TextEncoder().encode('')
+const SVG = new TextEncoder().encode('')
+
+describe('classifyProviderResponse', () => {
+ it.each([
+ [PNG, 'image/png'],
+ [ICO, 'image/x-icon'],
+ [JPEG, 'image/jpeg'],
+ [GIF, 'image/gif'],
+ [WEBP, 'image/webp'],
+ ] as const)('accepts a real icon and names its type from its bytes', (bytes, contentType) => {
+ expect(classifyProviderResponse(200, bytes)).toEqual({ kind: 'hit', contentType })
+ })
+
+ it('counts a placeholder served with 404 as a miss, though its body is an image', () => {
+ expect(classifyProviderResponse(404, PNG)).toEqual({ kind: 'miss' })
+ })
+
+ it.each([
+ ['HTML', HTML],
+ ['SVG', SVG],
+ ['an empty body', new Uint8Array()],
+ ])('counts %s served with 200 as a miss', (_label, bytes) => {
+ expect(classifyProviderResponse(200, bytes)).toEqual({ kind: 'miss' })
+ })
+
+ it('counts an oversized icon as a miss', () => {
+ const oversized = new Uint8Array(MAX_ICON_BYTES + 1)
+ oversized.set(PNG)
+ expect(classifyProviderResponse(200, oversized)).toEqual({ kind: 'miss' })
+ })
+
+ it('counts a 410 as a miss, like a 404', () => {
+ expect(classifyProviderResponse(410, PNG)).toEqual({ kind: 'miss' })
+ })
+
+ it.each([403, 408, 429, 500, 502, 503])('treats %i as unavailable, not as a miss', (status) => {
+ expect(classifyProviderResponse(status, PNG)).toEqual({ kind: 'unavailable' })
+ })
+})
diff --git a/apps/sim/lib/favicon/probe.ts b/apps/sim/lib/favicon/probe.ts
new file mode 100644
index 00000000000..7df7c3c1ed6
--- /dev/null
+++ b/apps/sim/lib/favicon/probe.ts
@@ -0,0 +1,31 @@
+import { sniffImageContentType } from '@/lib/uploads/utils/validation'
+
+/** Favicons are a few kilobytes; anything far larger is not one. */
+export const MAX_ICON_BYTES = 256 * 1024
+
+const ICO_MAGIC_BYTES = [0x00, 0x00, 0x01, 0x00]
+
+type ProviderResult =
+ | { kind: 'hit'; contentType: string }
+ | { kind: 'miss' }
+ | { kind: 'unavailable' }
+
+/** ICO is the one favicon format the shared raster sniffer doesn't recognize; SVG stays excluded. */
+function sniffIconType(bytes: Uint8Array): string | null {
+ if (ICO_MAGIC_BYTES.every((byte, index) => bytes[index] === byte)) return 'image/x-icon'
+ return sniffImageContentType(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength))
+}
+
+/**
+ * Whether a provider's answer is a real icon. Providers answer a host they have no icon for
+ * with a placeholder image under a 404, which a browser `
` would show as if it were one;
+ * only the status separates the two. Any other failure — a block, a timeout, a rate limit, a
+ * server error — is `unavailable`, so it is never remembered as "this site has no icon".
+ */
+export function classifyProviderResponse(status: number, bytes: Uint8Array): ProviderResult {
+ if (status === 404 || status === 410) return { kind: 'miss' }
+ if (status !== 200) return { kind: 'unavailable' }
+ if (bytes.length === 0 || bytes.length > MAX_ICON_BYTES) return { kind: 'miss' }
+ const contentType = sniffIconType(bytes)
+ return contentType ? { kind: 'hit', contentType } : { kind: 'miss' }
+}
diff --git a/apps/sim/lib/favicon/resolve.server.ts b/apps/sim/lib/favicon/resolve.server.ts
new file mode 100644
index 00000000000..26c7c08f638
--- /dev/null
+++ b/apps/sim/lib/favicon/resolve.server.ts
@@ -0,0 +1,146 @@
+import { createLogger } from '@sim/logger'
+import { getRedisClient } from '@/lib/core/config/redis'
+import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
+import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
+import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import { faviconHostCandidates } from '@/lib/favicon/hosts'
+import { classifyProviderResponse, MAX_ICON_BYTES } from '@/lib/favicon/probe'
+
+const logger = createLogger('Favicon')
+
+const PROVIDER_TIMEOUT_MS = 1500
+const RESOLVE_DEADLINE_MS = 4000
+const HIT_TTL_SECONDS = 30 * 24 * 60 * 60
+const MISS_TTL_SECONDS = 24 * 60 * 60
+const UNAVAILABLE_TTL_SECONDS = 5 * 60
+/** Cached values: an icon's bytes, or one of these markers. */
+const MISS_MARKER = ''
+const UNAVAILABLE_MARKER = '-'
+
+/**
+ * Icon providers, most complete first. Both answer a host they don't know with a 404, which
+ * is what lets a miss fall through to the next provider and then to the parent domain.
+ */
+const PROVIDERS: readonly ((host: string) => string)[] = [
+ (host) =>
+ `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=64`,
+ (host) => `https://icons.duckduckgo.com/ip3/${host}.ico`,
+]
+
+type FaviconResolution =
+ | { kind: 'hit'; contentType: string; bytes: Buffer }
+ | { kind: 'miss' }
+ | { kind: 'unavailable' }
+
+const inFlight = new Map>()
+
+const cacheKey = (host: string) => `favicon:v2:${host}`
+
+/** A host's key holds the result of the walk that starts at that host. */
+async function readCache(host: string): Promise {
+ const redis = getRedisClient()
+ if (!redis) return null
+ try {
+ const cached = await redis.getBuffer(cacheKey(host))
+ if (cached === null) return null
+ if (cached.toString() === UNAVAILABLE_MARKER) return { kind: 'unavailable' }
+ const result = classifyProviderResponse(200, cached)
+ return result.kind === 'hit' ? { ...result, bytes: cached } : { kind: 'miss' }
+ } catch (error) {
+ logger.warn('Favicon cache read failed', { error })
+ return null
+ }
+}
+
+async function writeCache(host: string, resolution: FaviconResolution): Promise {
+ const redis = getRedisClient()
+ if (!redis) return
+ const [value, ttl] =
+ resolution.kind === 'hit'
+ ? ([resolution.bytes, HIT_TTL_SECONDS] as const)
+ : resolution.kind === 'miss'
+ ? ([MISS_MARKER, MISS_TTL_SECONDS] as const)
+ : ([UNAVAILABLE_MARKER, UNAVAILABLE_TTL_SECONDS] as const)
+ try {
+ await redis.set(cacheKey(host), value, 'EX', ttl)
+ } catch (error) {
+ logger.warn('Favicon cache write failed', { error })
+ }
+}
+
+async function askProvider(url: string, signal: AbortSignal): Promise {
+ try {
+ const response = await secureFetchWithValidation(url, {
+ profile: 'contentFetch',
+ timeout: PROVIDER_TIMEOUT_MS,
+ maxRedirects: 2,
+ maxResponseBytes: MAX_ICON_BYTES,
+ signal,
+ headers: { Accept: 'image/png,image/x-icon,image/jpeg,image/gif,image/webp' },
+ })
+ let bytes = Buffer.alloc(0)
+ if (response.status === 200) bytes = Buffer.from(await response.arrayBuffer())
+ else await response.body?.cancel().catch(() => {})
+ const result = classifyProviderResponse(response.status, bytes)
+ return result.kind === 'hit' ? { ...result, bytes } : result
+ } catch (error) {
+ return isPayloadSizeLimitError(error) ? { kind: 'miss' } : { kind: 'unavailable' }
+ }
+}
+
+/** One provider at a time, so the next only learns a host the previous had no icon for. */
+async function askProviders(host: string, signal: AbortSignal): Promise {
+ let unavailable = false
+ for (const provider of PROVIDERS) {
+ const result = await askProvider(provider(host), signal)
+ if (result.kind === 'hit') return result
+ if (result.kind === 'unavailable') unavailable = true
+ }
+ return unavailable ? { kind: 'unavailable' } : { kind: 'miss' }
+}
+
+/**
+ * The most specific host first, so a tenant's own icon beats its platform's. A parent already
+ * resolved for a sibling subdomain is reused from the cache instead of asked again.
+ */
+async function walk(candidates: string[], signal: AbortSignal): Promise {
+ let unavailable = false
+ for (const [index, host] of candidates.entries()) {
+ const parent = index > 0
+ const cached = parent ? await readCache(host) : null
+ if (cached) return cached.kind === 'miss' && unavailable ? { kind: 'unavailable' } : cached
+ const result = await askProviders(host, signal)
+ if (result.kind === 'hit') {
+ if (parent) await writeCache(host, result)
+ return result
+ }
+ if (result.kind === 'unavailable') unavailable = true
+ }
+ return unavailable ? { kind: 'unavailable' } : { kind: 'miss' }
+}
+
+/**
+ * The icon for a host, or its nearest parent domain's, from public icon providers — the
+ * destination itself is never contacted, so a host behind a login still gets its platform's
+ * icon. Results are cached per host (30 days for an icon, a day for none, minutes after an
+ * outage), and concurrent lookups of one host in this process share a single resolution.
+ */
+export async function resolveFavicon(host: string): Promise {
+ const candidates = faviconHostCandidates(host)
+ const requested = candidates[0]
+ if (!requested) return { kind: 'miss' }
+ const pending = inFlight.get(requested)
+ if (pending) return pending
+
+ const resolution = (async (): Promise => {
+ const cached = await readCache(requested)
+ if (cached) return cached
+ const resolved = await runWithOutboundOrganization(null, () =>
+ walk(candidates, AbortSignal.timeout(RESOLVE_DEADLINE_MS))
+ ).catch((): FaviconResolution => ({ kind: 'unavailable' }))
+ await writeCache(requested, resolved)
+ return resolved
+ })().finally(() => inFlight.delete(requested))
+ inFlight.set(requested, resolution)
+ return resolution
+}
diff --git a/apps/sim/lib/favicon/url.ts b/apps/sim/lib/favicon/url.ts
new file mode 100644
index 00000000000..64d4a6a2cfb
--- /dev/null
+++ b/apps/sim/lib/favicon/url.ts
@@ -0,0 +1,6 @@
+import { getFaviconContract } from '@/lib/api/contracts/favicon'
+
+/** Sim's favicon route for a host; the browser never asks a third party for the icon. */
+export function faviconSrc(host: string): string {
+ return `${getFaviconContract.path}?host=${encodeURIComponent(host)}`
+}